diff --git a/.github/workflows/dart_ci.yml b/.github/workflows/dart_ci.yml index f41c86e4..121836d2 100644 --- a/.github/workflows/dart_ci.yml +++ b/.github/workflows/dart_ci.yml @@ -34,13 +34,19 @@ jobs: if: always() && steps.install.outcome == 'success' - name: Validate formatting - run: dart format --set-exit-if-changed . + run: dart run dart_dev format --check if: always() && steps.install.outcome == 'success' && matrix.sdk == '2.18.7' - name: Analyze project source run: dart analyze if: always() && steps.install.outcome == 'success' + - name: Ensure checked-in generated files are up to date + run: | + dart run build_runner build --delete-conflicting-outputs + git diff --exit-code + if: always() && steps.install.outcome == 'success' && matrix.sdk == '2.19.6' + test: runs-on: ubuntu-latest strategy: diff --git a/analysis_options.yaml b/analysis_options.yaml index 98828d29..92e3aa2d 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -5,4 +5,4 @@ analyzer: missing_required_param: error must_call_super: error exclude: - - test/test_fixtures + - test/test_fixtures/** diff --git a/bin/null_safety_required_props.dart b/bin/null_safety_required_props.dart new file mode 100644 index 00000000..243b83c1 --- /dev/null +++ b/bin/null_safety_required_props.dart @@ -0,0 +1,15 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export 'package:over_react_codemod/src/executables/null_safety_required_props.dart'; diff --git a/build.yaml b/build.yaml new file mode 100644 index 00000000..e79e8d6b --- /dev/null +++ b/build.yaml @@ -0,0 +1,11 @@ +targets: + $default: + builders: + json_serializable: + generate_for: + include: + - '**.sg.dart' + source_gen|combining_builder: + options: + ignore_for_file: + - implicit_dynamic_parameter diff --git a/lib/src/dart3_suggestors/null_safety_prep/class_component_required_default_props.dart b/lib/src/dart3_suggestors/null_safety_prep/class_component_required_default_props.dart index e3f9c425..0cf76e70 100644 --- a/lib/src/dart3_suggestors/null_safety_prep/class_component_required_default_props.dart +++ b/lib/src/dart3_suggestors/null_safety_prep/class_component_required_default_props.dart @@ -18,7 +18,7 @@ import 'package:over_react_codemod/src/util.dart'; import 'package:over_react_codemod/src/util/component_usage.dart'; import 'package:analyzer/dart/ast/ast.dart'; -import 'package:over_react_codemod/src/util/get_all_props.dart'; +import 'package:over_react_codemod/src/vendor/over_react_analyzer_plugin/get_all_props.dart'; import 'package:pub_semver/pub_semver.dart'; import 'utils/class_component_required_fields.dart'; diff --git a/lib/src/dart3_suggestors/null_safety_prep/class_component_required_initial_state.dart b/lib/src/dart3_suggestors/null_safety_prep/class_component_required_initial_state.dart index 525808b7..c2085070 100644 --- a/lib/src/dart3_suggestors/null_safety_prep/class_component_required_initial_state.dart +++ b/lib/src/dart3_suggestors/null_safety_prep/class_component_required_initial_state.dart @@ -17,7 +17,6 @@ import 'package:over_react_codemod/src/util/component_usage.dart'; import 'package:analyzer/dart/ast/ast.dart'; -import 'package:over_react_codemod/src/util/get_all_props.dart'; import 'package:over_react_codemod/src/util/get_all_state.dart'; import 'package:pub_semver/pub_semver.dart'; diff --git a/lib/src/dart3_suggestors/null_safety_prep/utils/class_component_required_fields.dart b/lib/src/dart3_suggestors/null_safety_prep/utils/class_component_required_fields.dart index 0dd7176c..7e7ce2ec 100644 --- a/lib/src/dart3_suggestors/null_safety_prep/utils/class_component_required_fields.dart +++ b/lib/src/dart3_suggestors/null_safety_prep/utils/class_component_required_fields.dart @@ -50,7 +50,7 @@ abstract class ClassComponentRequiredFieldsMigrator< final Set fieldData = {}; void patchFieldDeclarations( - List Function(InterfaceElement) getAll, + Iterable Function(InterfaceElement) getAll, Iterable cascadedDefaultPropsOrInitialState, CascadeExpression node) { for (final field in cascadedDefaultPropsOrInitialState) { @@ -77,7 +77,7 @@ abstract class ClassComponentRequiredFieldsMigrator< } VariableDeclaration? _getFieldDeclaration( - List Function(InterfaceElement) getAll, + Iterable Function(InterfaceElement) getAll, {required InterfaceElement propsOrStateElement, required String fieldName}) { // For component1 boilerplate its possible that `fieldEl` won't be found using `lookUpVariable` below diff --git a/lib/src/dart3_suggestors/null_safety_prep/utils/hint_detection.dart b/lib/src/dart3_suggestors/null_safety_prep/utils/hint_detection.dart index f8a82a0e..72cc40ea 100644 --- a/lib/src/dart3_suggestors/null_safety_prep/utils/hint_detection.dart +++ b/lib/src/dart3_suggestors/null_safety_prep/utils/hint_detection.dart @@ -33,10 +33,12 @@ bool nonNullableHintAlreadyExists(TypeAnnotation type) { const nonNullableHint = '/*!*/'; +const lateHint = '/*late*/'; + /// Whether the late hint already exists before [type] bool requiredHintAlreadyExists(TypeAnnotation type) { // Since the `/*late*/` comment is possibly adjacent to the prop declaration's doc comments, // we have to recursively traverse the `precedingComments` in order to determine if the `/*late*/` // comment actually exists. - return allComments(type.beginToken).any((t) => t.value() == '/*late*/'); + return allCommentsForNode(type).any((t) => t.value() == lateHint); } diff --git a/lib/src/dart3_suggestors/required_props/bin/aggregate.dart b/lib/src/dart3_suggestors/required_props/bin/aggregate.dart new file mode 100644 index 00000000..1c88e9a6 --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/bin/aggregate.dart @@ -0,0 +1,112 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:collection/collection.dart'; +import 'package:io/io.dart'; +import 'package:logging/logging.dart'; +import '../collect/aggregate.dart'; +import '../collect/logging.dart'; + +/// Aggregates individual data files, like what the collect command does, +/// but as a standalone command. +/// +/// This is leftover from before the collect command also aggregated data, +/// and is not publicly exposed, but is left in place just in case for +/// debugging purposes and potential future use. +/// +/// Also outputs some additional statistics. +Future main(List args) async { + final argParser = ArgParser() + ..addFlag('help', help: 'Print this usage information', negatable: false) + ..addOption( + 'output', + abbr: 'o', + help: 'The file to write output to.', + valueHelp: 'path', + defaultsTo: defaultAggregatedOutputFile, + ); + final parsedArgs = argParser.parse(args); + if (parsedArgs['help'] as bool) { + print(argParser.usage); + exit(ExitCode.success.code); + } + final outputFile = parsedArgs['output']! as String; + final filesToAggregate = parsedArgs.rest; + if (filesToAggregate.isEmpty) { + print('Must specify files to aggregate.\n${argParser.usage}'); + exit(ExitCode.usage.code); + } + + initLogging(); + final logger = Logger('prop_requiredness_aggregate'); + + logger.info('Loading results from files specified in arguments...'); + final allResults = loadResultFiles(filesToAggregate); + + { + // Gather some stats on how often different builder types show up. + final allUsages = allResults.expand((r) => r.usages); + + final countsByBuilderType = + allUsages.countBy((u) => u.usageBuilderType.name); + File('counts_by_builder_type.json') + .writeAsStringSync(jsonEncodeIndented(countsByBuilderType)); + + final countsByBuilderTypeByMixin = allUsages + .multiGroupListsBy((u) => u.mixinData.map((e) => e.mixinId)) + .map((mixinId, usages) => + MapEntry(mixinId, usages.countBy((u) => u.usageBuilderType.name))); + File('counts_by_builder_type_by_mixin.json') + .writeAsStringSync(jsonEncodeIndented(countsByBuilderTypeByMixin)); + } + + logger.info('Aggregating data...'); + final aggregated = aggregateData(allResults); + logger.info('Done.'); + + // logger.fine('Props mixins with the same name:'); + // final mixinIdsByName = aggregated.mixinMetadata.mixinNamesById.keysByValues(); + // mixinIdsByName.forEach((name, mixinIds) { + // if (mixinIds.length > 1) logger.fine('$name: ${mixinIds.map((id) => '\n - $id').join('')}'); + // }); + + File(outputFile).writeAsStringSync(jsonEncodeIndented(aggregated)); + logger.info('Wrote JSON results to $outputFile'); +} + +extension on Iterable { + Map countBy(T Function(E) getBucket) { + final counts = {}; + for (final element in this) { + final bucket = getBucket(element); + counts[bucket] = (counts[bucket] ?? 0) + 1; + } + return counts; + } + + /// Like [groupListsBy] but allows elements to be added to multiple groups. + Map> multiGroupListsBy(Iterable Function(E) keysOf) { + final groups = >{}; + for (final element in this) { + for (final key in keysOf(element)) { + groups.putIfAbsent(key, () => []).add(element); + } + } + return groups; + } +} diff --git a/lib/src/dart3_suggestors/required_props/bin/codemod.dart b/lib/src/dart3_suggestors/required_props/bin/codemod.dart new file mode 100644 index 00000000..22fec655 --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/bin/codemod.dart @@ -0,0 +1,169 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:codemod/codemod.dart'; +import 'package:over_react_codemod/src/dart3_suggestors/required_props/codemod/required_props_suggestor.dart'; +import 'package:over_react_codemod/src/util.dart'; +import 'package:over_react_codemod/src/util/args.dart'; +import 'package:over_react_codemod/src/util/command_runner.dart'; +import 'package:over_react_codemod/src/util/package_util.dart'; + +import '../codemod/recommender.dart'; +import '../collect/aggregated_data.sg.dart'; + +abstract class _Options { + static const propRequirednessData = 'prop-requiredness-data'; + static const privateRequirednessThreshold = 'private-requiredness-threshold'; + static const privateMaxAllowedSkipRate = 'private-max-allowed-skip-rate'; + static const publicRequirednessThreshold = 'public-requiredness-threshold'; + static const publicMaxAllowedSkipRate = 'public-max-allowed-skip-rate'; + + static const all = { + propRequirednessData, + privateRequirednessThreshold, + privateMaxAllowedSkipRate, + publicRequirednessThreshold, + publicMaxAllowedSkipRate + }; +} + +abstract class _Flags { + static const trustRequiredAnnotations = 'trust-required-annotations'; + static const all = { + trustRequiredAnnotations, + }; +} + +class CodemodCommand extends Command { + @override + String get description => + "Adds null safety migrator hints to OverReact props using prop requiredness data from 'collect' command."; + + @override + String get name => 'codemod'; + + @override + String get invocation => '$invocationPrefix []'; + + @override + String get usageFooter => ''' +\nInstructions +============ + +1. First, run the 'collect' command to collect data on usages of props declared + in your package (see that command's --help for instructions). + + $parentInvocationPrefix collect --help + +2. Run this command within the package you want to update: + + $invocationPrefix + +3. Inspect the TODO comments left over from the codemod. If you want to adjust + any thresholds or re-collect data, discard changes before re-running the codemod. + +4. Commit the changes made by the codemod. + +5. Proceed with using the Dart null safety migrator tool to migrate your code. + +6. Review TODO comments, adjusting requiredness if desired. You can use a + find-replace with the following regex to remove them: + + ${r'^ *// TODO\(orcm.required_props\):.+(?:\n *// .+)*'} +'''; + + CodemodCommand() { + argParser + ..addOption(_Options.propRequirednessData, + help: + "The file containing prop requiredness data, collected via the 'over_react_codemod:collect' command.", + defaultsTo: 'prop_requiredness.json') + ..addFlag(_Flags.trustRequiredAnnotations, + defaultsTo: true, + help: + 'Whether to migrate @requiredProp and `@nullableRequiredProp` props to late required, regardless of usage data.' + '\nNote that @requiredProp has no effect on function components, so these annotations may be incorrect.') + ..addOption(_Options.privateRequirednessThreshold, + defaultsTo: (0.95).toString(), + help: + 'The minimum rate (0.0-1.0) a private prop must be set to be considered required.') + ..addOption(_Options.privateMaxAllowedSkipRate, + defaultsTo: (0.2).toString(), + help: + 'The maximum allowed rate (0.0-1.0) of dynamic usages of private mixins, for which data collection was skipped.' + '\nIf above this, all props in a mixin will be made optional (with a TODO comment).') + ..addOption(_Options.publicRequirednessThreshold, + defaultsTo: (1).toString(), + help: + 'The minimum rate (0.0-1.0) a public prop must be set to be considered required.') + ..addOption(_Options.publicMaxAllowedSkipRate, + defaultsTo: (0.05).toString(), + help: + 'The maximum allowed rate (0.0-1.0) of dynamic usages of public mixins, for which data collection was skipped.' + '\nIf above this, all props in a mixin will be made optional (with a TODO comment).'); + + argParser.addSeparator('Codemod options'); + addCodemodArgs(argParser); + } + + @override + Future run() async { + final parsedArgs = this.argResults!; + final propRequirednessDataFile = + parsedArgs[_Options.propRequirednessData]! as String; + final codemodArgs = removeFlagArgs( + removeOptionArgs(parsedArgs.arguments, _Options.all), _Flags.all); + + final packageRoot = findPackageRootFor('.'); + await runPubGetIfNeeded(packageRoot); + final dartPaths = allDartPathsExceptHiddenAndGenerated(); + + final results = PropRequirednessResults.fromJson( + jsonDecode(File(propRequirednessDataFile).readAsStringSync())); + final recommender = PropRequirednessRecommender( + results, + privateRequirednessThreshold: + parsedArgs.argValueAsNumber(_Options.privateRequirednessThreshold), + privateMaxAllowedSkipRate: + parsedArgs.argValueAsNumber(_Options.privateMaxAllowedSkipRate), + publicRequirednessThreshold: + parsedArgs.argValueAsNumber(_Options.publicRequirednessThreshold), + publicMaxAllowedSkipRate: + parsedArgs.argValueAsNumber(_Options.publicMaxAllowedSkipRate), + ); + + exitCode = await runInteractiveCodemodSequence( + dartPaths, + [ + RequiredPropsSuggestor( + recommender, + trustRequiredAnnotations: + parsedArgs[_Flags.trustRequiredAnnotations] as bool, + ), + ], + defaultYes: true, + args: codemodArgs, + additionalHelpOutput: argParser.usage, + ); + } +} + +extension on ArgResults { + num argValueAsNumber(String name) => num.parse(this[name]); +} diff --git a/lib/src/dart3_suggestors/required_props/bin/collect.dart b/lib/src/dart3_suggestors/required_props/bin/collect.dart new file mode 100644 index 00000000..0d701836 --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/bin/collect.dart @@ -0,0 +1,315 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:logging/logging.dart'; +import 'package:over_react_codemod/src/dart3_suggestors/required_props/collect/aggregate.dart'; +import 'package:over_react_codemod/src/dart3_suggestors/required_props/collect/analysis.dart'; +import 'package:over_react_codemod/src/dart3_suggestors/required_props/collect/collect.dart'; +import 'package:over_react_codemod/src/util/command.dart'; +import 'package:over_react_codemod/src/util/command_runner.dart'; +import 'package:package_config/package_config.dart'; +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +import '../collect/collected_data.sg.dart'; +import '../collect/logging.dart'; +import '../collect/package/parse_spec.dart'; +import '../collect/package/spec.dart'; +import '../collect/package/version_manager.dart'; + +class CollectCommand extends Command { + @override + String get description => + 'Collects requiredness data for all OverReact props based on usages in the specified packages and all their transitive dependencies.'; + + @override + String get name => 'collect'; + + @override + String get invocation => + '$invocationPrefix [] [...]'; + + @override + String get usageFooter => + '\n$packageSpecFormatsHelpText\n\n$_usageInstructions'; + + String get _usageInstructions => ''' +Instructions +============ + +1. First, identify the least-common consumer(s) of OverReact components exposed by your package. + + (If all your package's components are private, you can skip the rest of this step, + step and just use your package). + + For example, say we're dealing with package A, which is directly consumed by + packages B, E, and F, and so on: + + ${r'A---B---C---D'} + ${r'|\ /'} + ${r'| E----'} + ${r'\'} + ${r' F---G---H'} + + The least-common consumers would be C (covers both B and E) and F, so we'd run: + + $invocationPrefix pub@…:C pub@…:F + + Note: if F were to re-export members of A, which could potentially get used + in G, we'd do G instead of F. + + $invocationPrefix pub@…:C pub@…:G + + Alternatively, we could just run on D and H from the start, but if those + packages include more transitive dependencies, then the analysis step of the + collection process will take a bit longer. + +2. If step 1 yielded more than one package, make sure all of them can resolve to + the latest version of your package. + + If they can't, then data may be missing for recently-added props, or could be + incorrect if props in your package were moved to different files. + + If you're not sure, try either: + - Cloning those packages, ensuring they resolve to the latest locally, + and providing them as local path package specs. + - Running the command and verifying the package versions in the command output + line up. + +3. Run the '$invocationPrefix' command with the packages from step 1, using + one of the package specifier formats listed above. + +4. Use the `codemod` command within the package you want to update + (see that command's --help for instructions): + + cd my_package + $parentInvocationPrefix codemod --help +'''; + + CollectCommand() { + argParser + ..addOption( + 'raw-data-output-directory', + help: 'An optional directory to output raw usage data file to.', + ) + ..addOption( + 'output', + abbr: 'o', + help: 'The file to write aggregated results to.', + valueHelp: 'path', + defaultsTo: defaultAggregatedOutputFile, + ) + ..addFlag( + 'verbose', + defaultsTo: false, + negatable: false, + help: 'Enable verbose output.', + ); + } + + @override + FutureOr? run() async { + final parsedArgs = this.argResults!; + + final aggregatedOutputFile = parsedArgs['output']! as String; + final verbose = parsedArgs['verbose']! as bool; + + var rawDataOutputDirectory = + parsedArgs['raw-data-output-directory'] as String?; + + final packageSpecStrings = parsedArgs.rest; + if (packageSpecStrings.isEmpty) { + usageException('Must specify package(s).'); + } + + initLogging(verbose: verbose); + + late final versionManager = PackageVersionManager.persistentSystemTemp(); + + final logger = Logger('prop_requiredness.collect'); + logger.info('Parsing/initializing package specs...'); + final packages = await Future.wait(packageSpecStrings.map((arg) { + return parsePackageSpec(arg, getVersionManager: () => versionManager); + })); + + logger + .info('Done. Package specs: ${packages.map((p) => '\n- $p').join('')}'); + + logger.info('Processing packages...'); + if (rawDataOutputDirectory != null) { + logger.info( + "Writing raw usage data to directory '$rawDataOutputDirectory'..."); + } + + final allResults = []; + + final processedPackages = {}; + for (final packageSpec in packages) { + logger.info('Processing $packageSpec...'); + final packageName = packageSpec.packageName; + if (processedPackages.contains(packageName)) { + throw Exception('Already processed $packageName'); + } + + final result = (await collectDataForPackage( + packageSpec, + processDependencyPackages: true, + skipIfAlreadyCollected: false, + skipIfNoUsages: false, + packageFilter: (p) => !processedPackages.contains(p.name), + outputDirectory: rawDataOutputDirectory, + ))!; + allResults.add(result); + logger.fine(result); + for (final otherPackage in result.results.otherPackageNames) { + if (processedPackages.contains(otherPackage)) { + throw Exception('$otherPackage was double-processed'); + } + processedPackages.add(otherPackage); + } + processedPackages.add(packageName); + } + logger.info('Done!'); + logger.fine('All results:\n${allResults.map((r) => '- $r\n').join('')}'); + logger.info( + 'All result files: ${allResults.map((r) => r.outputFilePath).join(' ')}'); + + logger.info('Aggregating raw usage data...'); + + final aggregated = aggregateData(allResults.map((r) => r.results).toList()); + + File(aggregatedOutputFile) + ..parent.createSync(recursive: true) + ..writeAsStringSync(jsonEncodeIndented(aggregated)); + logger.info( + 'Wrote aggregated prop requiredness data to ${aggregatedOutputFile}'); + } +} + +final jsonEncodeIndented = const JsonEncoder.withIndent(' ').convert; + +class CollectDataForPackageResult { + final PackageResults results; + final String? outputFilePath; + + CollectDataForPackageResult({ + required this.results, + required this.outputFilePath, + }); + + @override + String toString() => 'CollectDataForPackageResult(${{ + 'outputFilePath': outputFilePath, + 'results.otherPackageNames': results.otherPackageNames.toList(), + }})'; +} + +Future collectDataForPackage( + PackageSpec package, { + bool processDependencyPackages = false, + bool Function(Package)? packageFilter, + bool skipIfAlreadyCollected = true, + bool skipIfNoUsages = true, + String? outputDirectory, +}) async { + final rootPackageName = package.packageName; + final logger = Logger('prop_requiredness.${package.packageAndVersionId}'); + + File? outputFile; + if (outputDirectory != null) { + outputFile = File(p.normalize( + p.join(outputDirectory, '${package.packageAndVersionId}.json'))); + + if (skipIfAlreadyCollected && outputFile.existsSync()) { + final existingResults = tryParseResults(outputFile.readAsStringSync()); + if (existingResults != null && + existingResults.dataVersion == PackageResults.latestDataVersion) { + logger.info('Skipping since data already exists: ${outputFile.path}'); + return CollectDataForPackageResult( + results: existingResults, + outputFilePath: outputFile.path, + ); + } + } + } + + final packageInfo = await getPackageInfo(package); + // Heuristic to help filter out packages that don't contain over_react component usages, + // so we don't have to spend time resolving them. + if (skipIfNoUsages && + !packageInfo.libFiles + .any((l) => File(l).readAsStringSync().contains(')('))) { + logger.fine( + "Skipping package $rootPackageName since it doesn't look like it contains over_react usages"); + return null; + } + + logger.info('Performing pub upgrade to get newer versions of packages...'); + // Get latest dependencies, to get latest versions of other packages. + await runCommandAndThrowIfFailed('dart', ['pub', 'upgrade'], + workingDirectory: packageInfo.root); + + final packageVersionDescriptionsByName = { + rootPackageName: package.sourceDescription, + }; + final pubspecLock = loadYaml( + File(p.join(packageInfo.root, 'pubspec.lock')).readAsStringSync()) as Map; + (pubspecLock['packages'] as Map) + .cast() + .forEach((packageName, info) { + final version = info['version'] as String?; + if (version != null) { + packageVersionDescriptionsByName.putIfAbsent(packageName, () => version); + } + }); + packageVersionDescriptionsByName[rootPackageName] = package.sourceDescription; + logger.info('Package versions: ${packageVersionDescriptionsByName}'); + + logger.info("Analyzing and collecting raw usage data..."); + final units = getResolvedLibUnitsForPackage(package, + includeDependencyPackages: processDependencyPackages, + packageFilter: packageFilter); + + final results = await collectDataForUnits( + units, + rootPackageName: rootPackageName, + allowOtherPackageUnits: processDependencyPackages, + ); + results.packageVersionDescriptionsByName + .addAll(packageVersionDescriptionsByName); + + if (outputFile != null) { + outputFile.parent.createSync(recursive: true); + outputFile.writeAsStringSync(jsonEncode(results)); + logger.fine('Wrote data to ${outputFile.path}'); + } + + return CollectDataForPackageResult( + results: results, + outputFilePath: outputFile?.path, + ); +} + +dynamic tryParseJson(String content) { + try { + return jsonDecode(content); + } catch (_) { + return null; + } +} diff --git a/lib/src/dart3_suggestors/required_props/codemod/recommender.dart b/lib/src/dart3_suggestors/required_props/codemod/recommender.dart new file mode 100644 index 00000000..c51b4d32 --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/codemod/recommender.dart @@ -0,0 +1,165 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:analyzer/dart/element/element.dart'; +import '../collect/aggregated_data.sg.dart'; +import '../collect/util.dart'; + +/// A class that can provide recommendations for prop requiredness based on +/// [PropRequirednessResults] data. +class PropRequirednessRecommender { + final PropRequirednessResults _propRequirednessResults; + + final num privateRequirednessThreshold; + final num privateMaxAllowedSkipRate; + final num publicRequirednessThreshold; + final num publicMaxAllowedSkipRate; + + PropRequirednessRecommender( + this._propRequirednessResults, { + required this.privateRequirednessThreshold, + required this.privateMaxAllowedSkipRate, + required this.publicRequirednessThreshold, + required this.publicMaxAllowedSkipRate, + }) { + ({ + 'privateRequirednessThreshold': privateRequirednessThreshold, + 'privateMaxAllowedSkipRate': privateMaxAllowedSkipRate, + 'publicRequirednessThreshold': publicRequirednessThreshold, + 'publicMaxAllowedSkipRate': publicMaxAllowedSkipRate, + }).forEach((name, value) { + _validateWithinRange(value, name: name, min: 0, max: 1); + }); + } + + PropRecommendation? getRecommendation(FieldElement propField) { + final propName = propField.name; + + final mixinResults = _getMixinResult(propField.enclosingElement); + if (mixinResults == null) return null; + + final propResults = mixinResults.propResultsByName[propName]; + if (propResults == null) return null; + + final skipRateReason = _getMixinSkipRateReason(mixinResults); + if (skipRateReason != null) { + return PropRecommendation.optional(skipRateReason); + } + + final totalRequirednessRate = propResults.totalRate; + + final isPublic = mixinResults.visibility.isPublicForUsages; + final requirednessThreshold = + isPublic ? publicRequirednessThreshold : privateRequirednessThreshold; + + if (totalRequirednessRate < requirednessThreshold) { + final reason = RequirednessThresholdOptionalReason(); + return PropRecommendation.optional(reason); + } else { + return const PropRecommendation.required(); + } + } + + MixinResult? _getMixinResult(Element propsElement) { + final packageName = getPackageName(propsElement.source!.uri); + final propsId = uniqueElementId(propsElement); + return _propRequirednessResults.mixinResultsByIdByPackage[packageName] + ?[propsId]; + } + + SkipRateOptionalReason? _getMixinSkipRateReason(MixinResult mixinResults) { + final skipRate = mixinResults.usageSkipRate; + + final isPublic = mixinResults.visibility.isPublicForUsages; + final maxAllowedSkipRate = + isPublic ? publicMaxAllowedSkipRate : privateMaxAllowedSkipRate; + + return skipRate > maxAllowedSkipRate + ? SkipRateOptionalReason( + skipRate: skipRate, + maxAllowedSkipRate: maxAllowedSkipRate, + isPublic: isPublic) + : null; + } + + SkipRateOptionalReason? getMixinSkipRateReasonForElement( + Element propsElement) { + final mixinResults = _getMixinResult(propsElement); + if (mixinResults == null) return null; + + return _getMixinSkipRateReason(mixinResults); + } +} + +void _validateWithinRange(num value, + {required num min, required num max, required String name}) { + if (value < min || value > max) { + throw ArgumentError.value( + value, name, 'must be between $min and $max (inclusive)'); + } +} + +extension on Visibility { + bool get isPublicForUsages { + switch (this) { + case Visibility.public: + case Visibility.indirectlyPublic: + case Visibility.unknown: + return true; + case Visibility.private: + return false; + } + } + + // ignore: unused_element + bool get isPublicForMixingIn { + switch (this) { + case Visibility.public: + case Visibility.unknown: + return true; + case Visibility.indirectlyPublic: + case Visibility.private: + return false; + } + } +} + +class PropRecommendation { + final bool isRequired; + final OptionalReason? reason; + + const PropRecommendation.required() + : isRequired = true, + reason = null; + + const PropRecommendation.optional(this.reason) : isRequired = false; +} + +abstract class OptionalReason {} + +class SkipRateOptionalReason extends OptionalReason { + final num skipRate; + final num maxAllowedSkipRate; + final bool isPublic; + + SkipRateOptionalReason({ + required this.skipRate, + required this.maxAllowedSkipRate, + required this.isPublic, + }); +} + +class RequirednessThresholdOptionalReason extends OptionalReason { + RequirednessThresholdOptionalReason(); +} diff --git a/lib/src/dart3_suggestors/required_props/codemod/required_props_suggestor.dart b/lib/src/dart3_suggestors/required_props/codemod/required_props_suggestor.dart new file mode 100644 index 00000000..84f844da --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/codemod/required_props_suggestor.dart @@ -0,0 +1,190 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/token.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:collection/collection.dart'; +import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/utils/hint_detection.dart'; +import 'package:over_react_codemod/src/util.dart'; +import 'package:over_react_codemod/src/util/class_suggestor.dart'; + +import 'recommender.dart'; + +const _todoWithPrefix = 'TODO(orcm.required_props)'; + +class RequiredPropsSuggestor extends RecursiveAstVisitor + with ClassSuggestor { + final PropRequirednessRecommender _propRequirednessRecommender; + final bool _trustRequiredAnnotations; + + RequiredPropsSuggestor( + this._propRequirednessRecommender, { + required bool trustRequiredAnnotations, + }) : _trustRequiredAnnotations = trustRequiredAnnotations; + + @override + Future generatePatches() async { + final result = await context.getResolvedUnit(); + if (result == null) { + throw Exception( + 'Could not get resolved result for "${context.relativePath}"'); + } + result.unit.accept(this); + } + + @override + void visitMixinDeclaration(MixinDeclaration node) { + super.visitMixinDeclaration(node); + handleClassOrMixinElement(node, node.declaredElement); + } + + @override + void visitClassDeclaration(ClassDeclaration node) { + super.visitClassDeclaration(node); + handleClassOrMixinElement(node, node.declaredElement); + } + + void handleClassOrMixinElement( + NamedCompilationUnitMember node, InterfaceElement? element) { + if (element == null) return null; + + // Add a comment to let consumers know that we didn't have good enough data + // to make requiredness decision. + final skipReason = + _propRequirednessRecommender.getMixinSkipRateReasonForElement(element); + if (skipReason != null) { + String formatAsPercent(num number) => + '${(number * 100).toStringAsFixed(0)}%'; + + final skipRatePercent = formatAsPercent(skipReason.skipRate); + final maxAllowedSkipRatePercent = + formatAsPercent(skipReason.maxAllowedSkipRate); + + final commentContents = + "$_todoWithPrefix: This codemod couldn't reliably determine requiredness for these props" + "\n because $skipRatePercent of usages of components with these props" + " (> max allowed $maxAllowedSkipRatePercent for ${skipReason.isPublic ? 'public' : 'private'} props)" + "\n either contained forwarded props or were otherwise too dynamic to analyze." + "\n It may be possible to upgrade some from optional to required, with some manual inspection and testing."; + + final offset = node.firstTokenAfterCommentAndMetadata.offset; + yieldPatch(lineComment(commentContents), offset, offset); + } + } + + @override + void visitVariableDeclaration(VariableDeclaration node) { + super.visitVariableDeclaration(node); + + final fieldDeclaration = node.parentFieldDeclaration; + if (fieldDeclaration == null) return; + if (fieldDeclaration.isStatic) return; + if (fieldDeclaration.fields.isConst) return; + + final element = node.declaredElement; + if (element is! FieldElement) return; + + final type = fieldDeclaration.fields.type; + if (type != null && + (requiredHintAlreadyExists(type) || nullableHintAlreadyExists(type))) { + return; + } + + void yieldLateHintPatch() { + // Don't unnecessarily annotate it as non-nullable; + // let the migrator tool do that. + final offset = fieldDeclaration.firstTokenAfterCommentAndMetadata.offset; + yieldPatch('$lateHint ', offset, offset); + } + + void yieldOptionalHintPatch() { + if (type != null) { + yieldPatch(nullableHint, type.end, type.end); + } + } + + final requiredPropAnnotation = fieldDeclaration.metadata.firstWhereOrNull( + (m) => const {'requiredProp', 'nullableRequiredProp'} + .contains(m.name.name)); + + if (requiredPropAnnotation != null) { + // Always remove the annotation, since it can't be combined with late required props. + yieldPatch( + '', + requiredPropAnnotation.offset, + // Patch the whitespace up until the next token/comment, so that we take + // any newline along with this annotation. + requiredPropAnnotation.endToken.nextTokenOrCommentOffset ?? + requiredPropAnnotation.end); + + if (_trustRequiredAnnotations) { + yieldLateHintPatch(); + return; + } + } + + final recommendation = + _propRequirednessRecommender.getRecommendation(element); + + // No data; either not a prop, it's never actually set on any non-skipped usages, or our data is outdated. + if (recommendation == null) { + final skipReasonForEnclosingClass = _propRequirednessRecommender + .getMixinSkipRateReasonForElement(element.enclosingElement); + + final isPropsClass = skipReasonForEnclosingClass != null || + (node.declaredElement?.enclosingElement + ?.tryCast() + ?.allSupertypes + .any((s) => s.element.name == 'UiProps') ?? + false); + if (isPropsClass) { + // Only comment about missing data if we're not already making this optional + // because the class was skipped. + if (skipReasonForEnclosingClass == null) { + final commentContents = + "$_todoWithPrefix: No data for prop; either it's never set," + " all places it was set were on dynamic usages," + " or requiredness data was collected on a version before this prop was added."; + final offset = + fieldDeclaration.firstTokenAfterCommentAndMetadata.offset; + // Add back the indent we "stole" from the field by inserting our comment at its start. + yieldPatch(lineComment(commentContents) + ' ', offset, offset); + } + // Mark as optional + yieldOptionalHintPatch(); + } + return; + } + + if (recommendation.isRequired) { + yieldLateHintPatch(); + } else { + yieldOptionalHintPatch(); + } + } +} + +extension on Token { + /// The offset of the next token or comment + /// (since comments can occur before the next token) + /// following this token, or null if nothing follows it. + int? get nextTokenOrCommentOffset { + final next = this.next; + if (next == null) return null; + final nextTokenOrComment = next.precedingComments ?? next; + return nextTokenOrComment.offset; + } +} diff --git a/lib/src/dart3_suggestors/required_props/collect/aggregate.dart b/lib/src/dart3_suggestors/required_props/collect/aggregate.dart new file mode 100644 index 00000000..d0af29e4 --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/collect/aggregate.dart @@ -0,0 +1,409 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:logging/logging.dart'; +import 'aggregated_data.sg.dart'; +import 'collected_data.sg.dart'; + +const defaultAggregatedOutputFile = 'prop_requiredness.json'; + +final jsonEncodeIndented = const JsonEncoder.withIndent(' ').convert; + +List loadResultFiles(Iterable resultFiles) { + return resultFiles.map(File.new).map((file) { + PackageResults results; + try { + results = PackageResults.fromJson( + (jsonDecode(file.readAsStringSync()) as Map).cast()); + } catch (e, st) { + throw Exception('Error parsing results from file $file: $e\n$st'); + } + if (results.dataVersion != PackageResults.latestDataVersion) { + throw Exception('Outdated data version in $file'); + } + return results; + }).toList(); +} + +/// Aggregates individual prop usage data from [allResults] into prop +/// requiredness data. +PropRequirednessResults aggregateData( + List allResults, { + bool excludeOtherDynamicUsages = true, + bool excludeUsagesWithForwarded = true, + bool topLevelFactoryUsagesOnly = true, + bool outputDebugData = true, +}) { + final logger = Logger('aggregateData'); + + logger.finer('Checking for duplicates...'); + // Validate that there are no duplicates in the data set + { + final resultsByPackageName = >{}; + for (final result in allResults) { + for (final packageName in [ + result.packageName, + ...result.otherPackageNames + ]) { + resultsByPackageName.putIfAbsent(packageName, () => {}).add(result); + } + } + var duplicateResultsMessages = []; + resultsByPackageName.forEach((packageName, results) { + if (results.length != 1) { + duplicateResultsMessages.add( + 'Results for package $packageName were found in more than one results set:' + ' ${results.map((r) => 'PackageResults(packageName:$packageName)').toList()}'); + } + }); + if (duplicateResultsMessages.isNotEmpty) { + throw Exception( + 'Duplicate results:\n${duplicateResultsMessages.join('\n')}'); + } + } + + final mixinIdsByVisibilityByPackage = { + for (final result in allResults) ...result.mixinIdsByVisibilityByPackage, + }; + + final mismatchedMixinIdsByUsagePackage = >{}; + for (final result in allResults) { + for (final usage in result.usages) { + final usagePackage = usage.usagePackage; + for (final mixinData in usage.mixinData) { + final mixinPackage = mixinData.mixinPackage; + if (mixinPackage != usagePackage) { + final mixinId = mixinData.mixinId; + final mixinName = mixinData.mixinName; + final mixinIdsByVisibility = + mixinIdsByVisibilityByPackage[mixinPackage]; + if (mixinIdsByVisibility != null && + getVisibilityForMixinIdOrCompanion(mixinIdsByVisibility, + mixinId: mixinId, mixinName: mixinName) == + null) { + mismatchedMixinIdsByUsagePackage + .putIfAbsent(usagePackage, () => {}) + .add(mixinId); + } + } + } + } + } + + if (mismatchedMixinIdsByUsagePackage.isNotEmpty) { + logger.warning( + "Found usages of mixins in other packages that don't have declaration data:" + " ${mismatchedMixinIdsByUsagePackage.keys.toList()}"); + mismatchedMixinIdsByUsagePackage.forEach((packageName, mixinIds) { + logger.warning( + "- $packageName:\n${mixinIds.map((i) => ' - $i').join('\n')}"); + }); + } + + final usageStatsByMixinId = {}; + final allMixinIds = {}; + + final mixinNamesById = {}; + final mixinPackagesById = {}; + + UsageSkipReason? getUsageSkipReason(Usage usage) { + if (topLevelFactoryUsagesOnly && + usage.usageBuilderType != BuilderType.topLevelFactory) { + return UsageSkipReason.nonTopLevelFactory; + } + if (excludeUsagesWithForwarded && usage.usageHasForwardedProps) { + return UsageSkipReason.hasForwardedProps; + } + if (excludeOtherDynamicUsages && usage.usageHasOtherDynamicProps) { + return UsageSkipReason.hasOtherDynamicProps; + } + return null; + } + + final allUsages = allResults.expand((r) => r.usages); + + logger.finer('Tallying usages...'); + for (final usage in allUsages) { + final skipReason = getUsageSkipReason(usage); + + for (final mixin in usage.mixinData) { + allMixinIds.add(mixin.mixinId); + mixinNamesById[mixin.mixinId] = mixin.mixinName; + mixinPackagesById[mixin.mixinId] = mixin.mixinPackage; + + final isSamePackage = usage.usagePackage == mixin.mixinPackage; + + final categorizedStats = usageStatsByMixinId.putIfAbsent( + mixin.mixinId, CategorizedPropsMixinUsageStats.new); + + if (skipReason != null) { + categorizedStats.skippedUsages + ..countSkippedUsage(skipReason) + ..debugSkippedUsages.add(usage.usageId); + } else { + categorizedStats.skippedUsages.countNonSkippedUsage(); + for (final stats in [ + categorizedStats.total, + if (isSamePackage) + categorizedStats.samePackage + else + categorizedStats.otherPackage, + ]) { + stats.addPropsCounts(mixin.mixinPropsSet); + stats.usageCount++; + } + } + } + } + + // Do this in a second pass after we've processed all props and + // CategorizedPropsMixinUsageStats.allPropNames is complete for each mixin. + const unsetThreshold = 0.9; + const otherNames = {'renderInput', 'options'}; + logger.finer( + 'Collecting debug usages where props weren\'t set, using threshold $unsetThreshold'); + for (final usage in allUsages) { + if (getUsageSkipReason(usage) != null) continue; + + for (final mixin in usage.mixinData) { + final isSamePackage = usage.usagePackage == mixin.mixinPackage; + final categorizedStats = usageStatsByMixinId[mixin.mixinId]; + // We skipped it above. + if (categorizedStats == null) continue; + for (final propName in categorizedStats.allPropNames) { + if (!mixin.mixinPropsSet.contains(propName)) { + for (final stats in [ + categorizedStats.total, + if (isSamePackage) + categorizedStats.samePackage + else + categorizedStats.otherPackage, + ]) { + final rateForProp = stats.rateForProp(propName); + if (otherNames.contains(propName) || + (rateForProp != null && rateForProp >= unsetThreshold)) { + stats.debugUnsetPropUsages + .putIfAbsent(propName, () => []) + .add(usage.usageId); + } + } + } + } + } + } + + final results = PropRequirednessResults( + excludeOtherDynamicUsages: excludeOtherDynamicUsages, + excludeUsagesWithForwarded: excludeUsagesWithForwarded, + mixinResultsByIdByPackage: {}, + mixinMetadata: MixinMetadata( + mixinNamesById: mixinNamesById, + mixinPackagesById: mixinPackagesById, + ), + ); + + logger.finer('Aggregating final results...'); + for (final mixinId in allMixinIds) { + final stats = usageStatsByMixinId[mixinId]; + if (stats == null) continue; + + final mixinPackage = mixinPackagesById[mixinId]!; + final mixinIdsByVisibilityForPackage = + mixinIdsByVisibilityByPackage[mixinPackage]; + + final Visibility visibility; + if (mixinIdsByVisibilityForPackage == null) { + // If there's no data for public mixins for a package, + // then we don't know if it's public or not. + // We should have this data for all packages we've processed, but it can currently be null + // for packages that don't have any public entrypoints. + visibility = Visibility.unknown; + } else { + visibility = getVisibilityForMixinIdOrCompanion( + mixinIdsByVisibilityForPackage, + mixinId: mixinId, + mixinName: mixinNamesById[mixinId]!) ?? + Visibility.private; + } + + final propResultsByName = {}; + for (final propName in stats.allPropNames) { + final samePackageRate = stats.samePackage.rateForProp(propName); + final samePackageUsageCount = stats.samePackage.countForProp(propName); + + final otherPackageRate = stats.otherPackage.rateForProp(propName); + final otherPackageUsageCount = stats.otherPackage.countForProp(propName); + + // If we're processing this prop, it'll be non-null for total. + final totalRate = stats.total.rateForProp(propName)!; + final totalUsageCount = stats.total.countForProp(propName); + + propResultsByName[propName] = PropResult( + samePackageRate: samePackageRate, + otherPackageRate: otherPackageRate, + totalRate: totalRate, + samePackageUsageCount: samePackageUsageCount, + otherPackageUsageCount: otherPackageUsageCount, + totalUsageCount: totalUsageCount, + debugSamePackageUnsetUsages: outputDebugData + ? stats.samePackage.debugUnsetPropUsages[propName] + : null, + debugOtherPackageUnsetUsages: outputDebugData + ? stats.otherPackage.debugUnsetPropUsages[propName] + : null, + ); + } + + results.mixinResultsByIdByPackage + .putIfAbsent(mixinPackage, () => {})[mixinId] = MixinResult( + visibility: visibility, + usageSkipCount: stats.skippedUsages.skippedCount, + usageSkipRate: stats.skippedUsages.skipRate, + propResultsByName: propResultsByName, + debugSkippedUsages: + outputDebugData ? stats.skippedUsages.debugSkippedUsages : null, + ); + } + + logger.finer('Done.'); + + return results; +} + +Visibility? getVisibilityForMixinIdOrCompanion( + Map> mixinIdsByVisibility, { + required String mixinId, + required String mixinName, +}) { + late final companionId = (() { + const legacyBoilerplatePrefix = r'_$'; + if (mixinName.startsWith(legacyBoilerplatePrefix)) { + // Hack around legacy boilerplate mixins always being private; + // see if the public companion class is public. + final publicName = mixinName.substring(legacyBoilerplatePrefix.length); + return mixinId.replaceFirst(mixinName, publicName); + } + return null; + })(); + + final visibility = _getVisibility(mixinIdsByVisibility, mixinId); + late final companionVisibility = companionId == null + ? null + : _getVisibility(mixinIdsByVisibility, companionId); + + return visibility ?? companionVisibility; +} + +Visibility? _getVisibility( + Map> mixinIdsByVisibility, String someMixinId) { + // Prioritize public over indirectly exposed. + final visibilitiesInPriorityorder = + (LinkedHashSet.of({Visibility.public})..addAll(Visibility.values)); + return visibilitiesInPriorityorder.firstWhereOrNull((visibility) { + return mixinIdsByVisibility[visibility]?.contains(someMixinId) ?? false; + }); +} + +class CategorizedPropsMixinUsageStats { + final samePackage = PropsMixinUsageStats(); + final otherPackage = PropsMixinUsageStats(); + final total = PropsMixinUsageStats(); + + final skippedUsages = SkippedUsageStats(); + + Iterable get allPropNames => total.countsForProps.keys; +} + +class SkippedUsageStats { + var _nonSkippedUsageCount = 0; + final _skippedCountsByReason = {}; + + final List debugSkippedUsages = []; + + int get nonSkippedCount => _nonSkippedUsageCount; + + int get skippedCount => + _skippedCountsByReason.values.fold(0, (a, b) => a + b); + + int get totalCount => nonSkippedCount + skippedCount; + + num get skipRate { + if (totalCount == 0) { + throw StateError('Cannot compute skip rate when totalCount is 0.'); + } + return skippedCount / totalCount; + } + + void countNonSkippedUsage() { + _nonSkippedUsageCount++; + } + + void countSkippedUsage(UsageSkipReason reason) { + _skippedCountsByReason[reason] = (_skippedCountsByReason[reason] ?? 0) + 1; + } +} + +enum UsageSkipReason { + nonTopLevelFactory, + hasOtherDynamicProps, + hasForwardedProps, +} + +class PropsMixinUsageStats { + int usageCount = 0; + Map countsForProps = {}; + + Map> debugUnsetPropUsages = {}; + + void addPropsCounts(Iterable propNames) { + for (final propName in propNames) { + countsForProps[propName] = (countsForProps[propName] ?? 0) + 1; + } + } + + int countForProp(String propName) => countsForProps[propName] ?? 0; + + num? rateForProp(String propName) { + // Return null instead of a non-finite number. + if (usageCount == 0) return null; + return countForProp(propName) / usageCount; + } +} + +class PropsMixin { + final String mixinId; + final String packageName; + final String mixinName; + + PropsMixin._({ + required this.mixinId, + required this.packageName, + required this.mixinName, + }); + + factory PropsMixin.fromId(String mixinId) { + final mixinName = mixinId.split(' - ').first; + final packageName = RegExp(r'\bpackage:([^/]+)/').firstMatch(mixinId)![1]!; + return PropsMixin._( + mixinId: mixinId, + packageName: packageName, + mixinName: mixinName, + ); + } +} diff --git a/lib/src/dart3_suggestors/required_props/collect/aggregated_data.sg.dart b/lib/src/dart3_suggestors/required_props/collect/aggregated_data.sg.dart new file mode 100644 index 00000000..b450d00b --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/collect/aggregated_data.sg.dart @@ -0,0 +1,112 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:json_annotation/json_annotation.dart'; + +part 'aggregated_data.sg.g.dart'; + +@JsonSerializable() +class PropRequirednessResults { + final bool excludeOtherDynamicUsages; + final bool excludeUsagesWithForwarded; + + final Map> mixinResultsByIdByPackage; + + final MixinMetadata mixinMetadata; + + factory PropRequirednessResults.fromJson(Map json) => + _$PropRequirednessResultsFromJson(json); + + PropRequirednessResults({ + required this.excludeOtherDynamicUsages, + required this.excludeUsagesWithForwarded, + required this.mixinResultsByIdByPackage, + required this.mixinMetadata, + }); + + Map toJson() => _$PropRequirednessResultsToJson(this); +} + +@JsonSerializable() +class MixinMetadata { + final Map mixinNamesById; + final Map mixinPackagesById; + + MixinMetadata({ + required this.mixinNamesById, + required this.mixinPackagesById, + }); + + factory MixinMetadata.fromJson(Map json) => + _$MixinMetadataFromJson(json); + + Map toJson() => _$MixinMetadataToJson(this); +} + +@JsonSerializable(includeIfNull: false) +class MixinResult { + final Visibility visibility; + final int usageSkipCount; + final num usageSkipRate; + final Map propResultsByName; + final List? debugSkippedUsages; + + MixinResult({ + required this.visibility, + required this.usageSkipCount, + required this.usageSkipRate, + required this.propResultsByName, + this.debugSkippedUsages, + }); + + factory MixinResult.fromJson(Map json) => + _$MixinResultFromJson(json); + + Map toJson() => _$MixinResultToJson(this); +} + +@JsonSerializable(includeIfNull: false) +class PropResult { + final num? samePackageRate; + final num? otherPackageRate; + final num totalRate; + final int samePackageUsageCount; + final int otherPackageUsageCount; + final int totalUsageCount; + final List? debugSamePackageUnsetUsages; + final List? debugOtherPackageUnsetUsages; + + PropResult({ + required this.samePackageRate, + required this.otherPackageRate, + required this.totalRate, + required this.samePackageUsageCount, + required this.otherPackageUsageCount, + required this.totalUsageCount, + this.debugSamePackageUnsetUsages, + this.debugOtherPackageUnsetUsages, + }); + + factory PropResult.fromJson(Map json) => + _$PropResultFromJson(json); + + Map toJson() => _$PropResultToJson(this); +} + +enum Visibility { + public, + indirectlyPublic, + private, + unknown, +} diff --git a/lib/src/dart3_suggestors/required_props/collect/aggregated_data.sg.g.dart b/lib/src/dart3_suggestors/required_props/collect/aggregated_data.sg.g.dart new file mode 100644 index 00000000..f8c2f697 --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/collect/aggregated_data.sg.g.dart @@ -0,0 +1,126 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: implicit_dynamic_parameter + +part of 'aggregated_data.sg.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PropRequirednessResults _$PropRequirednessResultsFromJson( + Map json) => + PropRequirednessResults( + excludeOtherDynamicUsages: json['excludeOtherDynamicUsages'] as bool, + excludeUsagesWithForwarded: json['excludeUsagesWithForwarded'] as bool, + mixinResultsByIdByPackage: + (json['mixinResultsByIdByPackage'] as Map).map( + (k, e) => MapEntry( + k, + (e as Map).map( + (k, e) => + MapEntry(k, MixinResult.fromJson(e as Map)), + )), + ), + mixinMetadata: + MixinMetadata.fromJson(json['mixinMetadata'] as Map), + ); + +Map _$PropRequirednessResultsToJson( + PropRequirednessResults instance) => + { + 'excludeOtherDynamicUsages': instance.excludeOtherDynamicUsages, + 'excludeUsagesWithForwarded': instance.excludeUsagesWithForwarded, + 'mixinResultsByIdByPackage': instance.mixinResultsByIdByPackage, + 'mixinMetadata': instance.mixinMetadata, + }; + +MixinMetadata _$MixinMetadataFromJson(Map json) => + MixinMetadata( + mixinNamesById: Map.from(json['mixinNamesById'] as Map), + mixinPackagesById: + Map.from(json['mixinPackagesById'] as Map), + ); + +Map _$MixinMetadataToJson(MixinMetadata instance) => + { + 'mixinNamesById': instance.mixinNamesById, + 'mixinPackagesById': instance.mixinPackagesById, + }; + +MixinResult _$MixinResultFromJson(Map json) => MixinResult( + visibility: $enumDecode(_$VisibilityEnumMap, json['visibility']), + usageSkipCount: json['usageSkipCount'] as int, + usageSkipRate: json['usageSkipRate'] as num, + propResultsByName: + (json['propResultsByName'] as Map).map( + (k, e) => MapEntry(k, PropResult.fromJson(e as Map)), + ), + debugSkippedUsages: (json['debugSkippedUsages'] as List?) + ?.map((e) => e as String) + .toList(), + ); + +Map _$MixinResultToJson(MixinResult instance) { + final val = { + 'visibility': _$VisibilityEnumMap[instance.visibility]!, + 'usageSkipCount': instance.usageSkipCount, + 'usageSkipRate': instance.usageSkipRate, + 'propResultsByName': instance.propResultsByName, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('debugSkippedUsages', instance.debugSkippedUsages); + return val; +} + +const _$VisibilityEnumMap = { + Visibility.public: 'public', + Visibility.indirectlyPublic: 'indirectlyPublic', + Visibility.private: 'private', + Visibility.unknown: 'unknown', +}; + +PropResult _$PropResultFromJson(Map json) => PropResult( + samePackageRate: json['samePackageRate'] as num?, + otherPackageRate: json['otherPackageRate'] as num?, + totalRate: json['totalRate'] as num, + samePackageUsageCount: json['samePackageUsageCount'] as int, + otherPackageUsageCount: json['otherPackageUsageCount'] as int, + totalUsageCount: json['totalUsageCount'] as int, + debugSamePackageUnsetUsages: + (json['debugSamePackageUnsetUsages'] as List?) + ?.map((e) => e as String) + .toList(), + debugOtherPackageUnsetUsages: + (json['debugOtherPackageUnsetUsages'] as List?) + ?.map((e) => e as String) + .toList(), + ); + +Map _$PropResultToJson(PropResult instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('samePackageRate', instance.samePackageRate); + writeNotNull('otherPackageRate', instance.otherPackageRate); + val['totalRate'] = instance.totalRate; + val['samePackageUsageCount'] = instance.samePackageUsageCount; + val['otherPackageUsageCount'] = instance.otherPackageUsageCount; + val['totalUsageCount'] = instance.totalUsageCount; + writeNotNull( + 'debugSamePackageUnsetUsages', instance.debugSamePackageUnsetUsages); + writeNotNull( + 'debugOtherPackageUnsetUsages', instance.debugOtherPackageUnsetUsages); + return val; +} diff --git a/lib/src/dart3_suggestors/required_props/collect/analysis.dart b/lib/src/dart3_suggestors/required_props/collect/analysis.dart new file mode 100644 index 00000000..7d65f5ed --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/collect/analysis.dart @@ -0,0 +1,135 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:io'; + +import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:glob/glob.dart'; +import 'package:glob/list_local_fs.dart'; +import 'package:logging/logging.dart'; +import 'package:package_config/package_config.dart'; +import 'package:path/path.dart' as p; + +import 'package/spec.dart'; + +Future getPackageInfo(PackageSpec package) async { + final directory = await package.getDirectory(); + final pubspecFile = File(p.join(directory.path, 'pubspec.yaml')); + if (!pubspecFile.existsSync()) { + throw Exception('Expected to find a pubspec in ${pubspecFile.path}'); + } + final pubspecContent = await pubspecFile.readAsString(); + + final libDirectory = p.canonicalize(p.join(directory.path, 'lib')); + final canonicalizedPaths = + allDartFilesWithin(libDirectory).map(p.canonicalize).toList(); + if (canonicalizedPaths.isEmpty) { + throw Exception( + "No Dart files found in lib directory '$libDirectory'. Something probably went wrong."); + } + + return PackageInfo( + root: directory.path, + libFiles: canonicalizedPaths, + pubspecContent: pubspecContent, + libDirectory: libDirectory, + ); +} + +List allDartFilesWithin(String path) { + return Glob('**.dart', recursive: true) + .listSync(root: path) + .whereType() + .map((file) => file.path) + .toList(); +} + +class PackageInfo { + final String root; + final String libDirectory; + final List libFiles; + final String pubspecContent; + + PackageInfo({ + required this.root, + required this.libDirectory, + required this.libFiles, + required this.pubspecContent, + }); +} + +Stream getResolvedLibUnitsForPackage( + PackageSpec package, { + required bool includeDependencyPackages, + bool Function(Package)? packageFilter, +}) async* { + final logger = + Logger('getResolvedLibUnitsForPackage.${package.packageAndVersionId}'); + + final analyzeStopWatch = Stopwatch()..start(); + + final packageRoot = await package.getDirectory(); + final libDirectory = p.canonicalize(p.join(packageRoot.path, 'lib')); + final collection = AnalysisContextCollection(includedPaths: [libDirectory]); + final context = collection.contexts.single; + + Iterable? otherPackagesFiles; + if (includeDependencyPackages) { + final packagesFile = context.contextRoot.packagesFile; + if (packagesFile == null) { + throw Exception( + 'No packages file found for context with root ${context.contextRoot.workspace.root}'); + } + final resourceProvider = context.contextRoot.resourceProvider; + final packageConfig = + await loadPackageConfigUri(packagesFile.toUri(), loader: (uri) async { + return resourceProvider + .getFile(resourceProvider.pathContext.fromUri(uri)) + .readAsBytesSync(); + }); + final otherPackageRootPaths = packageConfig.packages + .where((p) => p.name != package.packageName) + .where((p) => packageFilter?.call(p) ?? true) + .map((p) => resourceProvider.pathContext.fromUri(p.packageUriRoot)); + otherPackagesFiles = + otherPackageRootPaths.map(p.canonicalize).expand(allDartFilesWithin); + } + + final filesToAnalyze = [ + ...context.contextRoot.analyzedFiles(), + ...?otherPackagesFiles, + ]; + + logger.finer('Processing units in ${package.packageName} package...'); + for (final path in filesToAnalyze) { + if (!path.endsWith('.dart')) continue; + + // Don't use collection.contextFor(path) since it fails for files in other packages. + final result = await context.currentSession.getResolvedUnit(path); + if (result is ResolvedUnitResult) { + if (result.exists) { + yield result; + } else { + logger.warning('File does not exist: $path'); + } + } else { + logger.warning('Issue resolving $path $result'); + } + } + + logger.finer( + 'Done. Analysis (and async iteration) took ${analyzeStopWatch.elapsed}'); + analyzeStopWatch.stop(); +} diff --git a/lib/src/dart3_suggestors/required_props/collect/collect.dart b/lib/src/dart3_suggestors/required_props/collect/collect.dart new file mode 100644 index 00000000..93bf0919 --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/collect/collect.dart @@ -0,0 +1,312 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:collection/collection.dart'; +import 'package:logging/logging.dart'; +import 'package:over_react_codemod/src/util.dart'; +import 'package:over_react_codemod/src/util/component_usage.dart'; +import 'package:over_react_codemod/src/vendor/over_react_analyzer_plugin/get_all_props.dart'; + +import 'collected_data.sg.dart'; +import 'logging.dart'; +import 'util.dart'; + +Future collectDataForUnits( + Stream units, { + required String rootPackageName, + required bool allowOtherPackageUnits, +}) async { + final logger = Logger('prop_requiredness.$rootPackageName'); + + final otherPackagesProcessed = {}; + final packageVersionDescriptionsByName = {}; + final allUsages = []; + final allMixinUsagesByMixinId = >{}; + final mixinIdsByVisibilityByPackage = + >>{}; + + await for (final unitResult in units) { + if (unitResult.uri.path.endsWith('.over_react.g.dart')) continue; + if (unitResult.libraryElement.isInSdk) continue; + + logProgress(); + + //logger.finest('Processing ${unitResult.uri}'); + + final unitElement = unitResult.unit.declaredElement; + if (unitElement == null) { + logger.warning('Failed to resolve ${unitResult.uri}'); + continue; + } + + final packageName = getPackageName(unitResult.uri); + if (packageName == null) { + throw Exception('Unexpected non-package URI: ${unitResult.uri}'); + } + + if (packageName != rootPackageName) { + if (!allowOtherPackageUnits) { + throw StateError( + 'Expected all units to be part of package $rootPackageName,' + ' but got one from package $packageName: ${unitResult.uri}'); + } + otherPackagesProcessed.add(packageName); + } + + allUsages.addAll(collectUsageDataForUnit( + unitResult: unitResult, + packageName: packageName, + )); + + // We'll get redundant results for libraries with multiple compilation units, + // but it doesn't matter since we're using a set, and it's not worth optimizing. + if (_isPublicPackageUri(unitResult.uri)) { + mixinIdsByVisibilityByPackage + .putIfAbsent(packageName, () => {}) + .putIfAbsent(Visibility.public, () => {}) + // Add exported props classes. + .addAll(unitResult.libraryElement.exportNamespace.definedNames.values + .whereType() + // Note that this is public relative to the library, not necessarily the package. + .where((element) => element.isPublic) + // Filter out non-props classes/mixins so we don't collect too much data. + .where((element) => element.name.contains('Props')) + .map(uniqueElementId)); + + // Add factories that indirectly expose props classes. + mixinIdsByVisibilityByPackage + .putIfAbsent(packageName, () => {}) + .putIfAbsent(Visibility.indirectlyPublic, () => {}) + .addAll(unitResult.libraryElement.exportNamespace.definedNames.values + .whereType() + .where((element) => element.isGetter) + // Note that this is public relative to the library, not necessarily the package. + .where((element) => element.isPublic) + // Filter out non-props classes/mixins so we don't collect too much data. + .map((element) { + final potentialPropsElement = element.returnType.typeOrBound + .tryCast() + ?.returnType + .element; + if (potentialPropsElement != null && + (potentialPropsElement.name?.contains('Props') ?? false)) { + return uniqueElementId(potentialPropsElement); + } + return null; + }).whereNotNull()); + } + + _collectMixinUsagesByMixin(unitElement) + .forEach((usedMixinId, usedByMixinIds) { + allMixinUsagesByMixinId + .putIfAbsent(usedMixinId, () => {}) + .addAll(usedByMixinIds); + }); + } + + return PackageResults( + packageName: rootPackageName, + otherPackageNames: otherPackagesProcessed, + packageVersionDescriptionsByName: packageVersionDescriptionsByName, + dataVersion: PackageResults.latestDataVersion, + usages: allUsages, + mixinIdsByVisibilityByPackage: mixinIdsByVisibilityByPackage, + allMixinUsagesByMixinId: allMixinUsagesByMixinId, + ); +} + +/// Returns [uri] is a package URI with a public (not under src/) path. +bool _isPublicPackageUri(Uri uri) { + if (!uri.isScheme('package')) return false; + // First path segment is the package name + return uri.pathSegments[1] != 'src'; +} + +List collectUsageDataForUnit({ + required ResolvedUnitResult unitResult, + required String packageName, +}) { + final logger = Logger('collectUsageData'); + + final allUsages = []; + + String uniqueNodeId(AstNode node) { + // Use line/column instead of the raw offset for easier debugging. + final location = unitResult.lineInfo.getLocation(node.offset); + return '${unitResult.uri}#$location'; + } + + unitResult.unit.accept(ComponentUsageVisitor((componentUsage) { + if (componentUsage.isDom) return; + + final usageId = uniqueNodeId(componentUsage.node); + + BuilderType builderType; + if (componentUsage.factory == null) { + builderType = BuilderType.otherBuilder; + } else if (componentUsage.factoryTopLevelVariableElement != null) { + builderType = BuilderType.topLevelFactory; + } else { + builderType = BuilderType.otherFactory; + } + + final dynamicPropsCategories = componentUsage.cascadedMethodInvocations + .map((c) { + final methodName = c.methodName.name; + late final arg = c.node.argumentList.arguments.firstOrNull; + + switch (methodName) { + case 'addUnconsumedProps': + return DynamicPropsCategory.forwarded; + case 'addAll': + case 'addProps': + if (arg is MethodInvocation && + (arg.methodName.name == 'getPropsToForward' || + arg.methodName.name == 'copyUnconsumedProps')) { + return DynamicPropsCategory.forwarded; + } + return DynamicPropsCategory.other; + case 'modifyProps': + if ((arg is MethodInvocation && + arg.methodName.name == 'addPropsToForward') || + (arg is Identifier && arg.name == 'addUnconsumedProps')) { + return DynamicPropsCategory.forwarded; + } + return DynamicPropsCategory.other; + } + + return null; + }) + .whereNotNull() + .toSet(); + final usageHasOtherDynamicProps = + dynamicPropsCategories.contains(DynamicPropsCategory.other); + final usageHasForwardedProps = + dynamicPropsCategories.contains(DynamicPropsCategory.forwarded); + + final builderPropsType = componentUsage + .builder.staticType?.typeOrBound.element + ?.tryCast(); + if (builderPropsType == null) { + logger + .warning('Could not resolve props; skipping usage. Usage: $usageId'); + return; + } + + List mixinData; + { + final assignedProps = + componentUsage.cascadedProps.where((p) => !p.isPrefixed).toSet(); + final assignedPropNames = assignedProps.map((p) => p.name.name).toSet(); + final unaccountedForPropNames = {...assignedPropNames}; + + // TODO maybe store mixin metadata separately? + + // [1] Use prop mixin elements and not the props, to account for setters that don't show up as prop fields + // (e.g., props that do conversion in getter/setter, props that alias other props). + final allPropMixins = + getAllPropsClassesOrMixins(builderPropsType).toSet(); // [1] + mixinData = allPropMixins.map((mixin) { + // [1] + final mixinPropsSet = assignedPropNames + .where((propName) => + mixin.getField(propName) != null || + mixin.getSetter(propName) != null) + .toSet(); + unaccountedForPropNames.removeAll(mixinPropsSet); + + final mixinPackage = getPackageName(mixin.librarySource.uri); + if (mixinPackage == null) { + throw Exception('Unexpected non-package URI: ${unitResult.uri}'); + } + + return UsageMixinData( + mixinPackage: mixinPackage, + mixinId: uniqueElementId(mixin), + mixinName: mixin.name, + // Note that for overridden props, they'll show up in multiple mixins + mixinPropsSet: mixinPropsSet, + ); + }).toList(); + + final unaccountedForProps = assignedProps + .where((p) => unaccountedForPropNames.contains(p.name.name)); + for (final prop in unaccountedForProps) { + final propsMixin = prop.staticElement?.enclosingElement; + if (propsMixin == null) continue; + + if (const { + 'ReactPropsMixin', + 'UbiquitousDomPropsMixin', + 'CssClassPropsMixin' + }.contains(propsMixin.name)) { + continue; + } + + // Edge-case: the deprecated FluxUiProps isn't picked up as a normal props class. + // We don't care about those for this script, so just bail. + if (propsMixin.name == 'FluxUiProps') { + continue; + } + + logger.warning( + 'Could not find corresponding mixin for prop ${prop.node.toSource()} for $usageId.' + ' enclosingElement from usage: ${uniqueElementId(propsMixin)},' + ' allPropsMixins from usage: ${allPropMixins.map(uniqueElementId).toList()}'); + } + } + + allUsages.add(Usage( + usageId: usageId, + usageUri: unitResult.uri.toString(), + usageDebugInfo: UsageDebugInfo( + usageBuilderSource: componentUsage.builder.toSource(), + ), + usagePackage: packageName, + usageHasOtherDynamicProps: usageHasOtherDynamicProps, + usageHasForwardedProps: usageHasForwardedProps, + usageBuilderType: builderType, + mixinData: mixinData, + )); + })); + + return allUsages; +} + +Map> _collectMixinUsagesByMixin( + CompilationUnitElement unitElement) { + final mixinUsagesByMixin = >{}; + + for (final cl in [unitElement.classes, unitElement.mixins].expand((i) => i)) { + final id = uniqueElementId(cl); + for (final mixin in getAllPropsClassesOrMixins(cl)) { + mixinUsagesByMixin.putIfAbsent(uniqueElementId(mixin), () => []).add(id); + } + } + + return mixinUsagesByMixin; +} + +extension ConditionalFunctionExtension1 on R Function(A) { + R? callIfNotNull(A? arg) => arg == null ? null : this(arg); +} + +enum DynamicPropsCategory { + other, + forwarded, +} diff --git a/lib/src/dart3_suggestors/required_props/collect/collected_data.sg.dart b/lib/src/dart3_suggestors/required_props/collect/collected_data.sg.dart new file mode 100644 index 00000000..87fa1349 --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/collect/collected_data.sg.dart @@ -0,0 +1,125 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:convert'; + +import 'package:json_annotation/json_annotation.dart'; + +import 'aggregated_data.sg.dart' show Visibility; +export 'aggregated_data.sg.dart' show Visibility; + +part 'collected_data.sg.g.dart'; + +@JsonSerializable() +class PackageResults { + static String latestDataVersion = '13'; + + final String dataVersion; + + final String packageName; + final Set otherPackageNames; + final Map packageVersionDescriptionsByName; + final List usages; + final Map>> mixinIdsByVisibilityByPackage; + final Map> allMixinUsagesByMixinId; + + factory PackageResults.fromJson(Map json) => + _$PackageResultsFromJson(json); + + PackageResults({ + required this.dataVersion, + required this.packageName, + required this.otherPackageNames, + required this.packageVersionDescriptionsByName, + required this.usages, + required this.mixinIdsByVisibilityByPackage, + required this.allMixinUsagesByMixinId, + }); + + Map toJson() => _$PackageResultsToJson(this); +} + +PackageResults? tryParseResults(String potentialJson) { + try { + return PackageResults.fromJson( + (jsonDecode(potentialJson) as Map).cast()); + } catch (_) { + return null; + } +} + +@JsonSerializable() +class Usage { + final String usageId; + final String usageUri; + final String usagePackage; + final UsageDebugInfo? usageDebugInfo; + final bool usageHasOtherDynamicProps; + final bool usageHasForwardedProps; + final BuilderType usageBuilderType; + final List mixinData; + + Usage({ + required this.usageId, + required this.usageUri, + required this.usagePackage, + this.usageDebugInfo, + required this.usageHasOtherDynamicProps, + required this.usageHasForwardedProps, + required this.usageBuilderType, + required this.mixinData, + }); + + factory Usage.fromJson(Map json) => _$UsageFromJson(json); + + Map toJson() => _$UsageToJson(this); +} + +@JsonSerializable() +class UsageDebugInfo { + final String usageBuilderSource; + + UsageDebugInfo({required this.usageBuilderSource}); + + factory UsageDebugInfo.fromJson(Map json) => + _$UsageDebugInfoFromJson(json); + + Map toJson() => _$UsageDebugInfoToJson(this); +} + +@JsonSerializable() +class UsageMixinData { + final String mixinPackage; + final String mixinId; + final String mixinName; + final Set mixinPropsSet; + + UsageMixinData({ + required this.mixinPackage, + required this.mixinId, + required this.mixinName, + required this.mixinPropsSet, + }); + + factory UsageMixinData.fromJson(Map json) => + _$UsageMixinDataFromJson(json); + + Map toJson() => _$UsageMixinDataToJson(this); +} + +enum BuilderType { + topLevelFactory, + otherFactory, + otherBuilder, +} diff --git a/lib/src/dart3_suggestors/required_props/collect/collected_data.sg.g.dart b/lib/src/dart3_suggestors/required_props/collect/collected_data.sg.g.dart new file mode 100644 index 00000000..5e0a9ac8 --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/collect/collected_data.sg.g.dart @@ -0,0 +1,121 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: implicit_dynamic_parameter + +part of 'collected_data.sg.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PackageResults _$PackageResultsFromJson(Map json) => + PackageResults( + dataVersion: json['dataVersion'] as String, + packageName: json['packageName'] as String, + otherPackageNames: (json['otherPackageNames'] as List) + .map((e) => e as String) + .toSet(), + packageVersionDescriptionsByName: Map.from( + json['packageVersionDescriptionsByName'] as Map), + usages: (json['usages'] as List) + .map((e) => Usage.fromJson(e as Map)) + .toList(), + mixinIdsByVisibilityByPackage: + (json['mixinIdsByVisibilityByPackage'] as Map).map( + (k, e) => MapEntry( + k, + (e as Map).map( + (k, e) => MapEntry($enumDecode(_$VisibilityEnumMap, k), + (e as List).map((e) => e as String).toSet()), + )), + ), + allMixinUsagesByMixinId: + (json['allMixinUsagesByMixinId'] as Map).map( + (k, e) => + MapEntry(k, (e as List).map((e) => e as String).toSet()), + ), + ); + +Map _$PackageResultsToJson(PackageResults instance) => + { + 'dataVersion': instance.dataVersion, + 'packageName': instance.packageName, + 'otherPackageNames': instance.otherPackageNames.toList(), + 'packageVersionDescriptionsByName': + instance.packageVersionDescriptionsByName, + 'usages': instance.usages, + 'mixinIdsByVisibilityByPackage': instance.mixinIdsByVisibilityByPackage + .map((k, e) => MapEntry(k, + e.map((k, e) => MapEntry(_$VisibilityEnumMap[k]!, e.toList())))), + 'allMixinUsagesByMixinId': instance.allMixinUsagesByMixinId + .map((k, e) => MapEntry(k, e.toList())), + }; + +const _$VisibilityEnumMap = { + Visibility.public: 'public', + Visibility.indirectlyPublic: 'indirectlyPublic', + Visibility.private: 'private', + Visibility.unknown: 'unknown', +}; + +Usage _$UsageFromJson(Map json) => Usage( + usageId: json['usageId'] as String, + usageUri: json['usageUri'] as String, + usagePackage: json['usagePackage'] as String, + usageDebugInfo: json['usageDebugInfo'] == null + ? null + : UsageDebugInfo.fromJson( + json['usageDebugInfo'] as Map), + usageHasOtherDynamicProps: json['usageHasOtherDynamicProps'] as bool, + usageHasForwardedProps: json['usageHasForwardedProps'] as bool, + usageBuilderType: + $enumDecode(_$BuilderTypeEnumMap, json['usageBuilderType']), + mixinData: (json['mixinData'] as List) + .map((e) => UsageMixinData.fromJson(e as Map)) + .toList(), + ); + +Map _$UsageToJson(Usage instance) => { + 'usageId': instance.usageId, + 'usageUri': instance.usageUri, + 'usagePackage': instance.usagePackage, + 'usageDebugInfo': instance.usageDebugInfo, + 'usageHasOtherDynamicProps': instance.usageHasOtherDynamicProps, + 'usageHasForwardedProps': instance.usageHasForwardedProps, + 'usageBuilderType': _$BuilderTypeEnumMap[instance.usageBuilderType]!, + 'mixinData': instance.mixinData, + }; + +const _$BuilderTypeEnumMap = { + BuilderType.topLevelFactory: 'topLevelFactory', + BuilderType.otherFactory: 'otherFactory', + BuilderType.otherBuilder: 'otherBuilder', +}; + +UsageDebugInfo _$UsageDebugInfoFromJson(Map json) => + UsageDebugInfo( + usageBuilderSource: json['usageBuilderSource'] as String, + ); + +Map _$UsageDebugInfoToJson(UsageDebugInfo instance) => + { + 'usageBuilderSource': instance.usageBuilderSource, + }; + +UsageMixinData _$UsageMixinDataFromJson(Map json) => + UsageMixinData( + mixinPackage: json['mixinPackage'] as String, + mixinId: json['mixinId'] as String, + mixinName: json['mixinName'] as String, + mixinPropsSet: (json['mixinPropsSet'] as List) + .map((e) => e as String) + .toSet(), + ); + +Map _$UsageMixinDataToJson(UsageMixinData instance) => + { + 'mixinPackage': instance.mixinPackage, + 'mixinId': instance.mixinId, + 'mixinName': instance.mixinName, + 'mixinPropsSet': instance.mixinPropsSet.toList(), + }; diff --git a/lib/src/dart3_suggestors/required_props/collect/logging.dart b/lib/src/dart3_suggestors/required_props/collect/logging.dart new file mode 100644 index 00000000..aab177f0 --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/collect/logging.dart @@ -0,0 +1,50 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:io'; + +import 'package:io/ansi.dart'; +import 'package:logging/logging.dart'; + +/// Flag to help keep logs and progress output on separate lines. +var lastLogWasProgress = false; + +void logProgress([String character = '.']) { + lastLogWasProgress = true; + stderr.write(character); +} + +void initLogging({bool verbose = false}) { + Logger.root.level = verbose ? Level.FINEST : Level.INFO; + Logger.root.onRecord.listen((record) { + if (lastLogWasProgress) stderr.writeln(); + lastLogWasProgress = false; + + AnsiCode color; + if (record.level < Level.WARNING) { + color = cyan; + } else if (record.level < Level.SEVERE) { + color = yellow; + } else { + color = red; + } + final message = StringBuffer()..write(color.wrap('[${record.level}] ')); + if (verbose) message.write('${record.loggerName}: '); + message.write(record.message); + print(message.toString()); + + if (record.error != null) print(record.error); + if (record.stackTrace != null) print(record.stackTrace); + }); +} diff --git a/lib/src/dart3_suggestors/required_props/collect/package/git.dart b/lib/src/dart3_suggestors/required_props/collect/package/git.dart new file mode 100644 index 00000000..b46b9b2e --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/collect/package/git.dart @@ -0,0 +1,85 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:io'; +import 'dart:math'; + +import 'package:logging/logging.dart'; +import 'package:over_react_codemod/src/util/command.dart'; +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +import 'spec.dart'; +import 'temp.dart'; + +Future gitRefPackageSpec(String repoUrl, String gitRef) async { + final cloneDirectory = await gitClone(repoUrl); + + Future runGitInheritStdio(List args) => + runCommandAndThrowIfFailedInheritIo('git', args, + workingDirectory: cloneDirectory.path); + + Future runGit(List args) => + runCommandAndThrowIfFailed('git', args, + workingDirectory: cloneDirectory.path); + + // Clear any local changes, such as a pubspec.lock that got updated upon pub get. + await runGit(['reset', '--hard']); + await runGitInheritStdio(['fetch', 'origin', gitRef]); + await runGit(['checkout', '--detach', 'FETCH_HEAD']); + + final commit = await runGit(['rev-parse', 'HEAD']); + final description = await + // Try using a tag first + runGit(['describe', '--exact-match', '--tags', 'HEAD']) + // then fall back to the commit + .onError((_, __) => commit); + + final packageName = (loadYamlNode( + File(p.join(cloneDirectory.path, 'pubspec.yaml')).readAsStringSync()) + as YamlMap)['name'] as String; + + return PackageSpec( + packageName: packageName, + versionId: commit, + sourceDescription: 'Git ref $gitRef: $description', + getDirectory: () async => cloneDirectory, + ); +} + +Future gitClone(String repoUrl, {String? parentDirectory}) async { + parentDirectory ??= packageTempDirectory().path; + + // 'git@example.com/foo/bar.git' -> ['foo', 'bar.git'] + // 'https://example.com/foo/bar.git' -> ['foo', 'bar.git'] + final cloneSubdirectory = + p.joinAll(repoUrl.split(':').last.split('/').takeLast(2)); + + final cloneDirectory = Directory(p.join(parentDirectory, cloneSubdirectory)); + if (!cloneDirectory.existsSync()) { + cloneDirectory.parent.createSync(recursive: true); + Logger('gitClone').fine('Cloning $repoUrl...'); + await runCommandAndThrowIfFailedInheritIo( + 'git', ['clone', repoUrl, cloneDirectory.path]); + } + + return cloneDirectory; +} + +extension on List { + List takeLast(int amount) { + RangeError.checkNotNegative(amount); + return sublist(max(length - amount, 0)); + } +} diff --git a/lib/src/dart3_suggestors/required_props/collect/package/local.dart b/lib/src/dart3_suggestors/required_props/collect/package/local.dart new file mode 100644 index 00000000..454ab4fb --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/collect/package/local.dart @@ -0,0 +1,32 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +import 'spec.dart'; + +PackageSpec localPathPackageSpec(String packageRoot) { + final packageName = (loadYamlNode( + File(p.join(packageRoot, 'pubspec.yaml')).readAsStringSync()) + as YamlMap)['name'] as String; + return PackageSpec( + packageName: packageName, + versionId: 'local-path', + sourceDescription: 'local path', + getDirectory: () async => Directory(packageRoot), + ); +} diff --git a/lib/src/dart3_suggestors/required_props/collect/package/metadata.dart b/lib/src/dart3_suggestors/required_props/collect/package/metadata.dart new file mode 100644 index 00000000..8e15984e --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/collect/package/metadata.dart @@ -0,0 +1,74 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; +import 'dart:io'; + +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as p; + +import 'transport.dart'; + +Future getLatestVersionOfPackage(String packageName, + {required String host}) async { + final packageInfo = + await getPackageInfo(packageName: packageName, host: host); + final latestVersion = (packageInfo['latest'] as Map)['version'] as String; + return latestVersion; +} + +// Fetch pub packages from a pub server +// Adapted from https://github.com/Workiva/cp_labs/blob/3c436d14cfaf958820dcf0a7ae44425f155c2bdb/tool/nsdash/bin/src/pub.dart#L10 +Future> fetchAllPackageNames(String host) async { + final logger = Logger('fetchPackages'); + logger.fine('Loading list of all packages from $host...'); + + Uri? uri = Uri.parse('$host/api/packages'); + var page = 0; + + final packageNames = []; + // Get ALL packages from a server + while (uri != null) { + if (page != 0) { + logger.finer('Fetching additional page $page: $uri'); + } + + page++; + // request the url + var response = await httpClient.newRequest().get(uri: uri); + if (response.status != 200) { + throw HttpException('${response.status} ${response.statusText}', + uri: uri); + } + final json = response.body.asJson() as Map; + + // get the next_url top level property if it exists, set url + final nextUrl = json['next_url'] as String?; + uri = nextUrl == null ? null : Uri.parse(nextUrl); + + for (final p in json['packages'] as List) { + final name = p['name'] as String; + packageNames.add(name); + } + } + logger.finer('Done. Loaded ${packageNames.length} packages from $page pages'); + return packageNames; +} + +Future getPackageInfo( + {required String packageName, required String host}) async { + final uri = Uri.parse(p.url.join(host, 'api/packages', packageName)); + final response = await httpClient.newRequest().get(uri: uri); + return (await response.body.asJson()) as Map; +} diff --git a/lib/src/dart3_suggestors/required_props/collect/package/parse_spec.dart b/lib/src/dart3_suggestors/required_props/collect/package/parse_spec.dart new file mode 100644 index 00000000..3e8e71d8 --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/collect/package/parse_spec.dart @@ -0,0 +1,97 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:io'; + +import 'package:collection/collection.dart'; + +import 'git.dart'; +import 'local.dart'; +import 'pub.dart'; +import 'spec.dart'; +import 'version_manager.dart'; + +const packageSpecFormatsHelpText = r''' +Supported package spec formats: +- Hosted pub package with optional version (uses latest if omitted): + - `pub@pub.dev:over_react` + - `pub@pub.dev:over_react#5.2.0` +- Git URL with optional revision: + - `git@github.com:Workiva/over_react.git` + - `https://github.com/Workiva/over_react.git` + - `git@github.com:Workiva/over_react.git#5.2.0` +- Local file path: + - `/path/to/over_react` + - `file:///path/to/over_react`'''; + +Future parsePackageSpec( + String packageSpecString, { + required PackageVersionManager Function() getVersionManager, +}) async { + Never invalidPackageSpec([String additionalMessage = '']) => + throw PackageSpecParseException(''' +Could not resolve package spec '$packageSpecString'.$additionalMessage + +$packageSpecFormatsHelpText'''); + + final uri = Uri.tryParse(packageSpecString); + if ((uri != null && uri.isScheme('https://')) || + packageSpecString.startsWith('git@')) { + final parts = packageSpecString.split('#'); + final repoUrl = parts[0]; + var ref = parts.skip(1).firstOrNull ?? ''; + if (ref.isEmpty) ref = 'HEAD'; + return gitRefPackageSpec(repoUrl, ref); + } + + if (packageSpecString.startsWith('pub@')) { + final pattern = RegExp(r'pub@(.+):(\w+)(?:#(.+))?$'); + final match = pattern.firstMatch(packageSpecString); + if (match == null) { + throw Exception( + "Pub formats must be 'pub@:(#version)'"); + } + var host = match[1]!; + if (!Uri.parse(host).hasScheme) { + host = 'https://$host'; + } + final packageName = match[2]!; + final version = match[3] ?? ''; + return pubPackageSpec( + packageName: packageName, + version: version.isEmpty ? null : version, + versionManager: getVersionManager(), + host: host.toString(), + ); + } + + if (uri != null && (!uri.hasScheme || uri.isScheme('file'))) { + final path = uri.toFilePath(); + if (!Directory(path).existsSync()) { + invalidPackageSpec(' If this is local path, it does not exist.'); + } + return localPathPackageSpec(path); + } + + invalidPackageSpec(); +} + +class PackageSpecParseException implements Exception { + final String message; + + PackageSpecParseException(this.message); + + @override + String toString() => 'PackageSpecParseException: $message'; +} diff --git a/lib/src/dart3_suggestors/required_props/collect/package/pub.dart b/lib/src/dart3_suggestors/required_props/collect/package/pub.dart new file mode 100644 index 00000000..3d2a11df --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/collect/package/pub.dart @@ -0,0 +1,59 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'metadata.dart'; +import 'spec.dart'; +import 'version_manager.dart'; + +PackageSpec packageSpecFromPackageVersion( + PackageVersion version, PackageVersionManager versionManager, + {String? sourceDescription}) { + sourceDescription ??= version.toString(); + return PackageSpec( + packageName: version.packageName, + versionId: version.version, + sourceDescription: sourceDescription, + getDirectory: () => versionManager.getExtractedFolder(version), + ); +} + +Future _resetPubspecLock(Directory directory) async { + // pubspec.lock shouldn't be included in published packages, so always delete it. + final pubspecLockFile = File(p.join(directory.path, 'pubspec.lock')); + if (pubspecLockFile.existsSync()) pubspecLockFile.deleteSync(); +} + +Future pubPackageSpec({ + required String packageName, + String? version, + required PackageVersionManager versionManager, + required String host, +}) async { + final useLatest = version == null; + + final packageVersion = PackageVersion( + hostUrl: host, + packageName: packageName, + version: useLatest + ? await getLatestVersionOfPackage(packageName, host: host) + : version, + ); + return packageSpecFromPackageVersion(packageVersion, versionManager, + sourceDescription: + useLatest ? '$packageVersion (latest version)' : '$packageVersion'); +} diff --git a/lib/src/dart3_suggestors/required_props/collect/package/spec.dart b/lib/src/dart3_suggestors/required_props/collect/package/spec.dart new file mode 100644 index 00000000..8a6a536c --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/collect/package/spec.dart @@ -0,0 +1,59 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:io'; + +/// A generic representation of a specific version of a package +/// that can also be used in various analysis tasks. +/// +/// Allows decoupling between the way a package is sourced and how it is analyzed. +/// +/// For example, this could be a hosted package from `VersionManager` (see `packageSpecFromPackageVersion`) +/// or any other package source (e.g., a cloned Git revision, a local working copy). +class PackageSpec { + /// The name of the package. + /// + /// This must match the package name in this package's pubspec.yaml. + final String packageName; + + /// A unique ID that can differentiate this package from others with the same [packageName]. + /// + /// Must contain only characters that can be used in a valid filename. + final String versionId; + + /// A human-readable description of the source of this package and version. + final String sourceDescription; + + /// Returns a future with a directory that has been populated with this package's contents. + /// + /// This directory should only be read from. + /// + /// For example, this function may download and extract a tarball of a hosted package, or check + /// out a revision in a Git clone. + final Future Function() getDirectory; + + /// A unique identifier containing [packageName] and [versionId]. + String get packageAndVersionId => '$packageName.$versionId'; + + PackageSpec({ + required this.packageName, + required this.versionId, + required this.sourceDescription, + required this.getDirectory, + }); + + @override + String toString() => + 'PackageSpec($packageName, $versionId) - $sourceDescription'; +} diff --git a/lib/src/dart3_suggestors/required_props/collect/package/temp.dart b/lib/src/dart3_suggestors/required_props/collect/package/temp.dart new file mode 100644 index 00000000..55f1d206 --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/collect/package/temp.dart @@ -0,0 +1,20 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:io'; +import 'package:path/path.dart' as p; + +Directory packageTempDirectory() => + Directory(p.join(Directory.systemTemp.path, 'over_react_codemod_packages')) + ..createSync(recursive: true); diff --git a/lib/src/dart3_suggestors/required_props/collect/package/transport.dart b/lib/src/dart3_suggestors/required_props/collect/package/transport.dart new file mode 100644 index 00000000..f995e4fc --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/collect/package/transport.dart @@ -0,0 +1,18 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:w_transport/vm.dart'; +import 'package:w_transport/w_transport.dart'; + +final httpClient = HttpClient(transportPlatform: VMTransportPlatform()); diff --git a/lib/src/dart3_suggestors/required_props/collect/package/version_manager.dart b/lib/src/dart3_suggestors/required_props/collect/package/version_manager.dart new file mode 100644 index 00000000..79798d1d --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/collect/package/version_manager.dart @@ -0,0 +1,136 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; +import 'dart:io' hide HttpClient; + +import 'package:logging/logging.dart'; +import 'package:over_react_codemod/src/util/command.dart'; +import 'package:path/path.dart' as p; + +import 'temp.dart'; + +// ------------------------------------------------------------------------------- +// +// Packages downloading and extracting +// +// ------------------------------------------------------------------------------- + +class PackageVersionManager { + static final logger = Logger('PackageVersionManager'); + + final String _cachePath; + + PackageVersionManager(this._cachePath); + + factory PackageVersionManager.persistentSystemTemp() { + final directory = + Directory(p.join(packageTempDirectory().path, 'version_manager')) + ..createSync(recursive: true); + return PackageVersionManager(directory.path); + } + + String get _downloadsFolder => p.join(_cachePath, 'downloads'); + + String get _extractedFolder => p.join(_cachePath, 'extracted'); + + String _hostAsDirectoryName(String url) => + Uri.parse(url).authority.replaceAll(RegExp(r'[^\w.]'), ''); + + String packageVersionName(PackageVersion version) => + '${version.packageName}-${version.version}'; + + String _downloadPath(PackageVersion version) => p.join( + _downloadsFolder, + _hostAsDirectoryName(version.hostUrl), + packageVersionName(version) + '.tar.gz'); + + String _extractedPath(PackageVersion version) => p.join(_extractedFolder, + _hostAsDirectoryName(version.hostUrl), packageVersionName(version)); + + Future _downloadPackage(PackageVersion version) async { + final downloadedFile = File(_downloadPath(version)); + if (!downloadedFile.existsSync()) { + logger.fine('Downloading $version...'); + + downloadedFile.parent.createSync(recursive: true); + await runCommandAndThrowIfFailed( + 'wget', [version.archiveUrl, '-O', downloadedFile.path]); + if (!downloadedFile.existsSync()) { + throw StateError( + 'Downloading file appeared to succeed, but file could not be found: ${downloadedFile.path}'); + } + } else { + logger.fine('Using cached download for $version'); + } + + return downloadedFile; + } + + Future _downloadAndExtractPackage(PackageVersion version) async { + final extractedDirectory = Directory(_extractedPath(version)); + if (!extractedDirectory.existsSync()) { + try { + final downloaded = await _downloadPackage(version); + logger.finer('Extracting $version...'); + extractedDirectory.createSync(recursive: true); + await runCommandAndThrowIfFailed('tar', [ + 'xzv', + '--directory', + extractedDirectory.path, + '--file', + downloaded.path + ]); + } catch (_) { + if (extractedDirectory.existsSync()) { + extractedDirectory.deleteSync(recursive: true); + } + rethrow; + } + } else { + logger.finer('Using already extracted folder for $version'); + final pubspecFile = File(p.join(extractedDirectory.path, 'pubspec.yaml')); + if (!pubspecFile.existsSync()) { + throw Exception('No pubspec file found at ${pubspecFile.path}.' + ' Either this package version is bad, or something went wrong with the download and extraction steps.' + ' Try deleting the following files/directories and running the script again:' + ' ${_downloadPath(version)}, ${extractedDirectory.path}'); + } + } + + return extractedDirectory; + } + + Future getExtractedFolder(PackageVersion version) => + _downloadAndExtractPackage(version); +} + +class PackageVersion { + final String packageName; + final String hostUrl; + final String version; + final String archiveUrl; + + PackageVersion({ + required this.packageName, + required this.hostUrl, + required this.version, + String? archiveUrl, + }) : archiveUrl = archiveUrl ?? + p.url.join( + hostUrl, '/packages/$packageName/versions/$version.tar.gz'); + + @override + String toString() => '$packageName $version (from $hostUrl)'; +} diff --git a/lib/src/dart3_suggestors/required_props/collect/util.dart b/lib/src/dart3_suggestors/required_props/collect/util.dart new file mode 100644 index 00000000..29c0d764 --- /dev/null +++ b/lib/src/dart3_suggestors/required_props/collect/util.dart @@ -0,0 +1,35 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:analyzer/dart/element/element.dart'; + +String? getPackageName(Uri uri) { + if (uri.scheme == 'package') return uri.pathSegments[0]; + return null; +} + +String uniqueElementId(Element element) { + // Use element.location so that we consolidate elements across different contexts + final location = element.location; + if (location != null) { + // Remove duplicate package URI + final components = {...location.components}.toList(); + // Move the package to the end so that the class shows up first, which is easier to read. + final pathIndex = components.indexWhere((c) => c.startsWith('package:')); + final path = pathIndex == -1 ? null : components.removeAt(pathIndex); + return [components.join(';'), if (path != null) path].join(' - '); + } + + return 'root:${element.session?.analysisContext.contextRoot},id:${element.id},${element.source?.uri},${element.name}'; +} diff --git a/lib/src/executables/null_safety_required_props.dart b/lib/src/executables/null_safety_required_props.dart new file mode 100644 index 00000000..72ed53fa --- /dev/null +++ b/lib/src/executables/null_safety_required_props.dart @@ -0,0 +1,34 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:io/io.dart'; +import 'package:over_react_codemod/src/dart3_suggestors/required_props/bin/codemod.dart'; +import 'package:over_react_codemod/src/dart3_suggestors/required_props/bin/collect.dart'; + +void main(List args) async { + final runner = CommandRunner("null_safety_required_props", + "Tooling to codemod over_react prop requiredness in preparation for null safety.") + ..addCommand(CollectCommand()) + ..addCommand(CodemodCommand()); + + try { + await runner.run(args); + } on UsageException catch (e) { + print(e); + exit(ExitCode.usage.code); + } +} diff --git a/lib/src/util/args.dart b/lib/src/util/args.dart new file mode 100644 index 00000000..d006d47f --- /dev/null +++ b/lib/src/util/args.dart @@ -0,0 +1,124 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:args/args.dart'; + +/// Removes arguments corresponding to options ('--'-prefixed arguments with values) +/// with names [optionArgNames] from args. +/// +/// Supports multiple arguments of the same name, and both `=` syntax +/// and value-as-a-separate-argument syntax. +/// +/// For example: +/// ``` +/// final originalArgs = [ +/// // Unrelated options +/// '--baz=baz', +/// // Multiple options of the same name +/// '--foo=1', +/// '--foo=2', +/// // Value as a separate argument +/// '--bar', +/// 'bar', +/// 'positionalArg', +/// ]; +/// final updatedArgs = removeOptionArgs(originalArgs, ['foo', 'bar']); +/// print(updatedArgs); // ['--baz=baz', 'positionalArg'] +/// ``` +List removeOptionArgs( + List args, Iterable optionArgNames) { + return optionArgNames.fold(args, (updatedArgs, argName) { + return _removeOptionOrFlagArgs(updatedArgs, argName, isOption: true); + }); +} + +/// Removes arguments corresponding to flags ('--'-prefixed arguments without values) +/// with names [flagArgNames] from args. +/// +/// Supports multiple flags of the same name, and negatable flags (prefixed by 'no-'). +/// +/// For example: +/// ``` +/// final originalArgs = [ +/// // Unrelated arguments +/// '--flag-to-keep', +/// // Multiple flags of the same name +/// '--flag-to-remove-1', +/// '--flag-to-remove-1', +/// // Inverted flag the same name +/// '--no-flag-to-remove-2', +/// 'positionalArg', +/// ]; +/// final updatedArgs = removeOptionArgs(originalArgs, ['flag-to-remove-1', 'flag-to-remove-2']); +/// print(updatedArgs); // ['--flag-to-keep', 'positionalArg'] +/// ``` +List removeFlagArgs(List args, Iterable flagArgNames) { + return flagArgNames.fold(args, (updatedArgs, argName) { + return _removeOptionOrFlagArgs(updatedArgs, argName, isOption: false); + }); +} + +List _removeOptionOrFlagArgs(List args, String argName, + {required bool isOption}) { + final updatedArgs = [...args]; + + final argPattern = isOption + ? RegExp(r'^--' + RegExp.escape(argName) + r'(=|$)') + : RegExp(r'^--(?:no-)?' + RegExp.escape(argName) + r'$'); + + int argIndex; + while ((argIndex = updatedArgs.indexWhere(argPattern.hasMatch)) != -1) { + final matchingArg = updatedArgs[argIndex]; + bool isOptionWithMultiArgSyntax() { + if (!isOption) return false; + final equalsOrEndGroup = argPattern.firstMatch(matchingArg)![1]!; + return equalsOrEndGroup != '='; + } + + if (isOptionWithMultiArgSyntax() && argIndex != updatedArgs.length - 1) { + updatedArgs.removeRange(argIndex, argIndex + 2); + } else { + updatedArgs.removeAt(argIndex); + } + } + + return updatedArgs; +} + +/// Returns a new ArgParser that redefines arguments supported in codemod's ArgParser, +/// so that they can be forwarded along without consumers needing to use `--`. +void addCodemodArgs(ArgParser argParser) => argParser + ..addFlag( + 'verbose', + abbr: 'v', + negatable: false, + help: 'Outputs all logging to stdout/stderr.', + ) + ..addFlag( + 'yes-to-all', + negatable: false, + help: 'Forces all patches accepted without prompting the user. ' + 'Useful for scripts.', + ) + ..addFlag( + 'fail-on-changes', + negatable: false, + help: 'Returns a non-zero exit code if there are changes to be made. ' + 'Will not make any changes (i.e. this is a dry-run).', + ) + ..addFlag( + 'stderr-assume-tty', + negatable: false, + help: 'Forces ansi color highlighting of stderr. Useful for debugging.', + ); diff --git a/lib/src/util/command.dart b/lib/src/util/command.dart new file mode 100644 index 00000000..c7bec2c5 --- /dev/null +++ b/lib/src/util/command.dart @@ -0,0 +1,41 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:io'; + +Future runCommandAndThrowIfFailed(String command, List args, + {String? workingDirectory, bool returnErr = false}) async { + final result = + await Process.run(command, args, workingDirectory: workingDirectory); + + if (result.exitCode != 0) { + throw ProcessException( + command, args, '${result.stdout}${result.stderr}', result.exitCode); + } + + return ((returnErr ? result.stderr : result.stdout) as String).trim(); +} + +Future runCommandAndThrowIfFailedInheritIo( + String command, List args, + {String? workingDirectory}) async { + final process = await Process.start(command, args, + workingDirectory: workingDirectory, mode: ProcessStartMode.inheritStdio); + + final exitCode = await process.exitCode; + + if (exitCode != 0) { + throw ProcessException(command, args, '', exitCode); + } +} diff --git a/lib/src/util/command_runner.dart b/lib/src/util/command_runner.dart new file mode 100644 index 00000000..501bb050 --- /dev/null +++ b/lib/src/util/command_runner.dart @@ -0,0 +1,66 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:args/command_runner.dart'; + +extension CommandExtension on Command { + // Adapted from https://github.com/dart-lang/args/blob/1a24d614423e7861ae2e341bfb19050959cef0cd/lib/command_runner.dart#L283 + // + // Copyright 2013, the Dart project authors. + // + // Redistribution and use in source and binary forms, with or without + // modification, are permitted provided that the following conditions are + // met: + // + // * Redistributions of source code must retain the above copyright + // notice, this list of conditions and the following disclaimer. + // * Redistributions in binary form must reproduce the above + // copyright notice, this list of conditions and the following + // disclaimer in the documentation and/or other materials provided + // with the distribution. + // * Neither the name of Google LLC nor the names of its + // contributors may be used to endorse or promote products derived + // from this software without specific prior written permission. + // + // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + /// Returns the first part of the default string returned by [Command.invocation], + /// containing the string of nested executables/commands needed to run this command, + /// without the `` or `arguments` portion. + String get invocationPrefix => [parentInvocationPrefix, name].join(' '); + + /// Same as [invocationPrefix], but doesn't include the current command. + String get parentInvocationPrefix => [ + ...parentCommands.map((p) => p.name), + runner!.executableName, + ].reversed.join(' '); + + Iterable get parentCommands sync* { + final parent = this.parent; + if (parent == null) return; + + yield parent; + yield* parent.parentCommands; + } +} diff --git a/lib/src/util/component_usage.dart b/lib/src/util/component_usage.dart index 0a1f4c6f..e758b2f9 100644 --- a/lib/src/util/component_usage.dart +++ b/lib/src/util/component_usage.dart @@ -426,6 +426,9 @@ mixin PropOrStateAssignment on BuilderMemberAccess { /// The expression for the right hand side of this assignment. Expression get rightHandSide => node.rightHandSide; + + /// The element that declares the prop being assigned. + Element? get staticElement => node.writeElement; } abstract class StateAssignment extends BuilderMemberAccess diff --git a/lib/src/util/get_all_props.dart b/lib/src/vendor/over_react_analyzer_plugin/get_all_props.dart similarity index 82% rename from lib/src/util/get_all_props.dart rename to lib/src/vendor/over_react_analyzer_plugin/get_all_props.dart index c62f4d85..aedda3d0 100644 --- a/lib/src/util/get_all_props.dart +++ b/lib/src/vendor/over_react_analyzer_plugin/get_all_props.dart @@ -1,5 +1,3 @@ -// Taken from https://github.com/Workiva/over_react/blob/5c6e1742949ce4e739f7923b799cc00b1118279b/tools/analyzer_plugin/lib/src/util/prop_declarations/get_all_props.dart - // Copyright 2024 Workiva Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -14,26 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Adapted from https://github.com/Workiva/over_react/blob/9079dff9405448ddaa5743a0ae408c4e0a056cec/tools/analyzer_plugin/lib/src/util/prop_declarations/get_all_props.dart + import 'package:analyzer/dart/element/element.dart'; import 'package:collection/collection.dart'; // Performance optimization notes: -// [1] Building a list here is slightly more optimal than using a generator. -// // [2] Ideally we'd check the library and package here, // but for some reason accessing `.library` is pretty inefficient. // Possibly due to the `LibraryElementImpl? get library => thisOrAncestorOfType();` impl, // and an inefficient `thisOrAncestorOfType` impl: https://github.com/dart-lang/sdk/issues/53255 -/// Returns all props defined in a props class/mixin [propsElement] as well as all of its supertypes, -/// except for those shared by all UiProps instances by default (e.g., ReactPropsMixin, UbiquitousDomPropsMixin, -/// and CssClassPropsMixin's key, ref, children, className, id, onClick, etc.). -/// -/// Each returned field will be the consumer-declared prop field, and the list will not contain overrides -/// from generated over_react parts. -/// -/// Excludes any fields annotated with `@doNotGenerate`. -List getAllProps(InterfaceElement propsElement) { +Iterable getAllPropsClassesOrMixins( + InterfaceElement propsElement) sync* { final propsAndSupertypeElements = propsElement.thisAndSupertypesList; // There are two UiProps; one in component_base, and one in builder_helpers that extends from it. @@ -49,12 +40,11 @@ List getAllProps(InterfaceElement propsElement) { final inheritsFromUiProps = uiPropsElement != null; late final isPropsMixin = propsElement.metadata.any(_isPropsMixinAnnotation); if (!inheritsFromUiProps && !isPropsMixin) { - return []; + return; } final uiPropsAndSupertypeElements = uiPropsElement?.thisAndSupertypesSet; - final allProps = []; // [1] for (final interface in propsAndSupertypeElements) { // Don't process UiProps or its supertypes // (Object, Map, MapBase, MapViewMixin, PropsMapViewMixin, ReactPropsMixin, UbiquitousDomPropsMixin, CssClassPropsMixin, ...) @@ -76,23 +66,40 @@ List getAllProps(InterfaceElement propsElement) { continue; } - for (final field in interface.fields) { - if (field.isStatic) continue; - if (field.isSynthetic) continue; - - final accessorAnnotation = _getAccessorAnnotation(field.metadata); - final isNoGenerate = accessorAnnotation - ?.computeConstantValue() - ?.getField('doNotGenerate') - ?.toBoolValue() ?? - false; - if (isNoGenerate) continue; + yield interface; + } +} - allProps.add(field); - } +Iterable getPropsDeclaredInMixin( + InterfaceElement interface) sync* { + for (final field in interface.fields) { + if (field.isStatic) continue; + if (field.isSynthetic) continue; + + final accessorAnnotation = _getAccessorAnnotation(field.metadata); + final isNoGenerate = accessorAnnotation + ?.computeConstantValue() + ?.getField('doNotGenerate') + ?.toBoolValue() ?? + false; + if (isNoGenerate) continue; + + yield field; } +} - return allProps; +/// Returns all props defined in a props class/mixin [propsElement] as well as all of its supertypes, +/// except for those shared by all UiProps instances by default (e.g., ReactPropsMixin, UbiquitousDomPropsMixin, +/// and CssClassPropsMixin's key, ref, children, className, id, onClick, etc.). +/// +/// Each returned field will be the consumer-declared prop field, and the list will not contain overrides +/// from generated over_react parts. +/// +/// Excludes any fields annotated with `@doNotGenerate`. +Iterable getAllProps(InterfaceElement propsElement) sync* { + for (final interface in getAllPropsClassesOrMixins(propsElement)) { + yield* getPropsDeclaredInMixin(interface); + } } bool _isOneOfThePropsAnnotations(ElementAnnotation e) { diff --git a/pubspec.yaml b/pubspec.yaml index 8c16a258..833ea4cb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,7 +8,7 @@ description: > environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=2.15.0 <3.0.0' dependencies: analyzer: ^5.0.0 @@ -16,8 +16,10 @@ dependencies: codemod: ^1.0.1 collection: ^1.15.0 glob: ^2.0.1 + json_annotation: ^4.8.0 logging: ^1.0.1 meta: '>=1.7.0 <1.10.0' + package_config: ^2.1.0 path: ^1.8.0 pub_semver: ^2.0.0 source_span: ^1.8.1 @@ -25,13 +27,16 @@ dependencies: yaml_edit: ^2.0.0 file: ^6.1.2 io: ^1.0.0 + w_transport: ^5.2.1 dev_dependencies: async: ^2.0.0 build_runner: ^2.1.1 build_test: ^2.1.3 + dart_dev: ^4.1.1 dart_style: ^2.0.3 dependency_validator: ^4.0.0 + json_serializable: ^6.5.3 test: ^1.17.10 test_descriptor: ^2.0.0 uuid: ^3.0.5 @@ -41,6 +46,7 @@ executables: dart2_9_upgrade: dependency_validator_ignore: null_safety_migrator_companion: + null_safety_required_props: null_safety_prep: mui_migration: required_flux_props: diff --git a/test/executables/required_props_collect_and_codemod_test.dart b/test/executables/required_props_collect_and_codemod_test.dart new file mode 100644 index 00000000..a4e61a98 --- /dev/null +++ b/test/executables/required_props_collect_and_codemod_test.dart @@ -0,0 +1,346 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:async/async.dart'; +import 'package:meta/meta.dart'; +import 'package:over_react_codemod/src/util/command.dart'; +import 'package:over_react_codemod/src/util/package_util.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +// Change this to `true` and all of the functional tests in this file will print +// the stdout/stderr of the codemod processes. +final _debug = false; + +void main() { + group( + 'null_safety_required_props collect and codemod command, end-to-end behavior:', + () { + final requiredPropsScript = p.join( + findPackageRootFor(p.current), 'bin/null_safety_required_props.dart'); + + const name = 'test_package'; + late d.DirectoryDescriptor projectDir; + late String dataFilePath; + + setUpAll(() async { + print('setUpAll: Collecting data...'); + final tmpDir = + Directory.systemTemp.createTempSync('required_props_codemod_test'); + dataFilePath = p.join(tmpDir.path, 'prop_requiredness.json'); + await runCommandAndThrowIfFailed('dart', [ + requiredPropsScript, + 'collect', + '--output', + dataFilePath, + p.join(findPackageRootFor(p.current), + 'test/test_fixtures/required_props/test_consuming_package'), + ]); + expect(File(dataFilePath).existsSync(), isTrue); + print('setUpAll: Done.'); + }); + + setUp(() async { + projectDir = d.DirectoryDescriptor.fromFilesystem( + name, + p.join(findPackageRootFor(p.current), + 'test/test_fixtures/required_props/test_package')); + await projectDir.create(); + }); + + const noDataTodoComment = + r"// TODO(orcm.required_props): No data for prop; either it's never set, all places it was set were on dynamic usages, or requiredness data was collected on a version before this prop was added."; + + test('adds hints as expected in different cases', () async { + await testCodemod( + script: requiredPropsScript, + args: [ + 'codemod', + '--prop-requiredness-data', + dataFilePath, + '--yes-to-all', + ], + input: projectDir, + expectedOutput: d.dir(projectDir.name, [ + d.dir('lib', [ + d.dir('src', [ + d.file('test_private.dart', contains(''' +mixin TestPrivateProps on UiProps { + /*late*/ String set100percent; + String/*?*/ set80percent; + String/*?*/ set20percent; + $noDataTodoComment + String/*?*/ set0percent; +}''')), + d.file('test_private_dynamic.dart', contains(''' +// TODO(orcm.required_props): This codemod couldn't reliably determine requiredness for these props +// because 75% of usages of components with these props (> max allowed 20% for private props) +// either contained forwarded props or were otherwise too dynamic to analyze. +// It may be possible to upgrade some from optional to required, with some manual inspection and testing. +mixin TestPrivateDynamicProps on UiProps { + String/*?*/ set100percent; +}''')), + d.file('test_private_existing_hints.dart', contains(''' +mixin TestPrivateExistingHintsProps on UiProps { + /*late*/ String set100percentWithoutHint; + /*late*/ String set100percent; + String/*?*/ set80percent; + String/*?*/ set0percent; +}''')), + ]), + ]), + ]), + ); + }); + + group('makes props with required over_react annotations late', () { + test('by default', () async { + await testCodemod( + script: requiredPropsScript, + args: [ + 'codemod', + '--prop-requiredness-data', + dataFilePath, + '--yes-to-all', + ], + input: projectDir, + expectedOutput: d.dir(projectDir.name, [ + d.dir('lib', [ + d.dir('src', [ + // Note that there's no to-do comment on annotatedRequiredPropSet0Percent + // since we short-circuit the logic that inserts it when trusting the annotation. + d.file('test_required_annotations.dart', contains(''' +mixin TestRequiredAnnotationsProps on UiProps { + /*late*/ String annotatedRequiredProp; + /*late*/ String annotatedNullableRequiredProp; + + /*late*/ String annotatedRequiredPropSet50Percent; + /*late*/ String annotatedRequiredPropSet0Percent; + + /// Doc comment + /*late*/ String annotatedRequiredPropWithDocComment; +}''')), + ]), + ]), + ]), + ); + }); + + test('unless consumers pass --no-trust-required-annotation', () async { + await testCodemod( + script: requiredPropsScript, + args: [ + 'codemod', + '--prop-requiredness-data', + dataFilePath, + '--no-trust-required-annotations', + '--yes-to-all', + ], + input: projectDir, + expectedOutput: d.dir(projectDir.name, [ + d.dir('lib', [ + d.dir('src', [ + d.file('test_required_annotations.dart', contains(''' +mixin TestRequiredAnnotationsProps on UiProps { + /*late*/ String annotatedRequiredProp; + /*late*/ String annotatedNullableRequiredProp; + + String/*?*/ annotatedRequiredPropSet50Percent; + $noDataTodoComment + String/*?*/ annotatedRequiredPropSet0Percent; + + /// Doc comment + /*late*/ String annotatedRequiredPropWithDocComment; +}''')), + ]), + ]), + ]), + ); + }); + }); + + test('allows customizing requiredness thresholds via command line options', + () async { + await testCodemod( + script: requiredPropsScript, + args: [ + 'codemod', + '--prop-requiredness-data', + dataFilePath, + '--private-requiredness-threshold=0.1', + '--public-requiredness-threshold=0.7', + '--yes-to-all', + ], + input: projectDir, + expectedOutput: d.dir(projectDir.name, [ + d.dir('lib', [ + d.dir('src', [ + d.file('test_private.dart', contains(''' +mixin TestPrivateProps on UiProps { + /*late*/ String set100percent; + /*late*/ String set80percent; + /*late*/ String set20percent; + $noDataTodoComment + String/*?*/ set0percent; +}''')), + d.file('test_public_multiple_components.dart', contains(''' +mixin TestPublicUsedByMultipleComponentsProps on UiProps { + /*late*/ String set100percent; + /*late*/ String set80percent; + String/*?*/ set20percent; + $noDataTodoComment + String/*?*/ set0percent; +}''')) + ]), + ]), + ]), + ); + }); + + group('allows customizing skip thresholds via command line options', () { + // Don't test both private and public above/below the threshold, + // so that tests ensure the private/public numbers don't get mixed up somewhere along the way. + test('private props below threshold, public above', () async { + await testCodemod( + script: requiredPropsScript, + args: [ + 'codemod', + '--prop-requiredness-data', + dataFilePath, + '--private-max-allowed-skip-rate=0.12', + '--public-max-allowed-skip-rate=0.9', + '--yes-to-all', + ], + input: projectDir, + expectedOutput: d.dir(projectDir.name, [ + d.dir('lib', [ + d.dir('src', [ + d.file('test_private_dynamic.dart', contains(''' +// TODO(orcm.required_props): This codemod couldn't reliably determine requiredness for these props +// because 75% of usages of components with these props (> max allowed 12% for private props) +// either contained forwarded props or were otherwise too dynamic to analyze. +// It may be possible to upgrade some from optional to required, with some manual inspection and testing. +mixin TestPrivateDynamicProps on UiProps { + String/*?*/ set100percent; +}''')), + d.file('test_public_dynamic.dart', contains(''' +mixin TestPublicDynamicProps on UiProps { + /*late*/ String set100percent; +}''')) + ]), + ]), + ]), + ); + }); + + test('private props below threshold, public above', () async { + await testCodemod( + script: requiredPropsScript, + args: [ + 'codemod', + '--prop-requiredness-data', + dataFilePath, + '--private-max-allowed-skip-rate=0.9', + '--public-max-allowed-skip-rate=0.34', + '--yes-to-all', + ], + input: projectDir, + expectedOutput: d.dir(projectDir.name, [ + d.dir('lib', [ + d.dir('src', [ + d.file('test_private_dynamic.dart', contains(''' +mixin TestPrivateDynamicProps on UiProps { + /*late*/ String set100percent; +}''')), + d.file('test_public_dynamic.dart', contains(''' +// TODO(orcm.required_props): This codemod couldn't reliably determine requiredness for these props +// because 80% of usages of components with these props (> max allowed 34% for public props) +// either contained forwarded props or were otherwise too dynamic to analyze. +// It may be possible to upgrade some from optional to required, with some manual inspection and testing. +mixin TestPublicDynamicProps on UiProps { + String/*?*/ set100percent; +}''')) + ]), + ]), + ]), + ); + }); + }); + }, timeout: Timeout(Duration(minutes: 2))); +} + +// Adapted from `testCodemod` in https://github.com/Workiva/dart_codemod/blob/c5d245308554b0e1e7a15a54fbd2c79a9231e2be/test/functional/run_interactive_codemod_test.dart#L39 +// Intentionally does not run `pub get` on the project. +@isTest +Future testCodemod({ + required String script, + required d.DirectoryDescriptor input, + d.DirectoryDescriptor? expectedOutput, + List? args, + void Function(String out, String err)? body, + int? expectedExitCode, + List? stdinLines, +}) async { + final projectDir = input; + + final processArgs = [ + script, + ...?args, + ]; + if (_debug) { + processArgs.add('--verbose'); + } + final process = await Process.start('dart', processArgs, + workingDirectory: projectDir.io.path); + + // If _debug, split these single-subscription streams into two + // so that we can display the output as it comes in. + final stdoutStreams = StreamSplitter.splitFrom( + process.stdout.transform(utf8.decoder), _debug ? 2 : 1); + final stderrStreams = StreamSplitter.splitFrom( + process.stderr.transform(utf8.decoder), _debug ? 2 : 1); + if (_debug) { + stdoutStreams[1] + .transform(LineSplitter()) + .forEach((line) => print('STDOUT: $line')); + stderrStreams[1] + .transform(LineSplitter()) + .forEach((line) => print('STDERR: $line')); + } + + stdinLines?.forEach(process.stdin.writeln); + final codemodExitCode = await process.exitCode; + expectedExitCode ??= 0; + + final codemodStdout = await stdoutStreams[0].join(); + final codemodStderr = await stderrStreams[0].join(); + + expect(codemodExitCode, expectedExitCode, + reason: 'Expected codemod to exit with code $expectedExitCode, but ' + 'it exited with $codemodExitCode.\n' + 'Process stderr:\n$codemodStderr'); + + if (expectedOutput != null) { + // Expect that the modified projet matches the gold files. + await expectedOutput.validate(); + } + + if (body != null) { + body(codemodStdout, codemodStderr); + } +} diff --git a/test/executables/required_props_collect_test.dart b/test/executables/required_props_collect_test.dart new file mode 100644 index 00000000..3387fb29 --- /dev/null +++ b/test/executables/required_props_collect_test.dart @@ -0,0 +1,290 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:over_react_codemod/src/dart3_suggestors/required_props/collect/aggregated_data.sg.dart'; +import 'package:over_react_codemod/src/util/command.dart'; +import 'package:over_react_codemod/src/util/package_util.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +main() { + group('null_safety_required_props collect command', () { + late PropRequirednessResults aggregated; + + setUpAll(() async { + // Use this instead for local dev if you want to run collection manually + // as opposed to on every test run. + // final localDevAggregatedOutputFile = 'prop_requiredness.json'; + // aggregated = PropRequirednessResults.fromJson( + // jsonDecode(File(localDevAggregatedOutputFile).readAsStringSync())); + aggregated = await collectAndAggregateDataForTestPackage(); + }); + + group('collects expected data', () { + test('for visibility of props mixins', () { + const expectedVisibilities = { + 'TestPrivateProps': Visibility.private, + 'TestPublicProps': Visibility.public, + 'TestFactoryOnlyExportedProps': Visibility.indirectlyPublic, + }; + final actualVisibiilities = { + for (final name in expectedVisibilities.keys) + name: aggregated.mixinResultsByName(name).visibility + }; + expect(actualVisibiilities, expectedVisibilities); + }); + + group('for private props used within their own package:', () { + test('set rate', () { + final mixinResults = + aggregated.mixinResultsByName('TestPrivateProps'); + expect( + mixinResults.propResultsByName.mapValues((v) => v.samePackageRate), + allOf( + containsPair('set100percent', 1.0), + containsPair('set80percent', 0.8), + containsPair('set20percent', 0.2), + ), + ); + expect( + mixinResults.propResultsByName.mapValues((v) => v.totalRate), + allOf( + containsPair('set100percent', 1.0), + containsPair('set80percent', 0.8), + containsPair('set20percent', 0.2), + ), + ); + expect( + mixinResults.propResultsByName.mapValues((v) => v.otherPackageRate), + allOf( + containsPair('set100percent', null), + containsPair('set80percent', null), + containsPair('set20percent', null), + ), + reason: + 'props only used in the same package should not have otherPackageRate populated', + ); + + expect(mixinResults.usageSkipRate, 0); + }); + + test('set rate when used by multiple components', () { + final mixinResults = aggregated + .mixinResultsByName('TestPrivateUsedByMultipleComponentsProps'); + expect( + mixinResults.propResultsByName.mapValues((v) => v.samePackageRate), + allOf( + containsPair('set100percent', 1.0), + containsPair('set80percent', 0.8), + containsPair('set20percent', 0.2), + ), + ); + expect( + mixinResults.propResultsByName.mapValues((v) => v.totalRate), + allOf( + containsPair('set100percent', 1.0), + containsPair('set80percent', 0.8), + containsPair('set20percent', 0.2), + ), + ); + expect( + mixinResults.propResultsByName.mapValues((v) => v.otherPackageRate), + allOf( + containsPair('set100percent', null), + containsPair('set80percent', null), + containsPair('set20percent', null), + ), + reason: + 'props only used in the same package should not have otherPackageRate populated', + ); + + expect(mixinResults.usageSkipRate, 0); + }); + + group('skip rate:', () { + test('props that are never skipped', () { + final mixinResults = + aggregated.mixinResultsByName('TestPrivateProps'); + expect(mixinResults.usageSkipRate, 0); + expect(mixinResults.usageSkipCount, 0); + }); + + group('props that are skipped due to', () { + test('dynamic prop additions', () { + expect(aggregated.excludeOtherDynamicUsages, isTrue, + reason: 'test setup check'); + + final mixinResults = + aggregated.mixinResultsByName('TestPrivateDynamicProps'); + const expectedSkipCount = 3; + const expectedTotalUsages = 4; + expect(mixinResults.usageSkipCount, expectedSkipCount); + expect(mixinResults.usageSkipRate, + expectedSkipCount / expectedTotalUsages); + }); + + test('forwarded props', () { + expect(aggregated.excludeUsagesWithForwarded, isTrue, + reason: 'test setup check'); + + final mixinResults = + aggregated.mixinResultsByName('TestPrivateForwardedProps'); + const expectedSkipCount = 5; + const expectedTotalUsages = 6; + expect(mixinResults.usageSkipCount, expectedSkipCount); + expect(mixinResults.usageSkipRate, + expectedSkipCount / expectedTotalUsages); + }); + }); + }); + }); + + group('for public props used in multiple packages:', () { + test('set rate', () { + final mixinResults = aggregated.mixinResultsByName('TestPublicProps'); + expect( + mixinResults.propResultsByName.mapValues((v) => v.totalRate), + allOf( + containsPair('set100percent', 1.0), + containsPair('set20percent', 0.2), + ), + ); + expect( + mixinResults.propResultsByName.mapValues((v) => v.samePackageRate), + allOf( + containsPair('set100percent', 1.0), + containsPair('set20percent', anyOf(null, 0.0)), + ), + ); + expect( + mixinResults.propResultsByName.mapValues((v) => v.otherPackageRate), + allOf( + containsPair('set100percent', 1.0), + containsPair('set20percent', 1.0), + ), + ); + + expect(mixinResults.usageSkipRate, 0); + }); + + test('set rate when used by multiple components', () { + final mixinResults = aggregated + .mixinResultsByName('TestPublicUsedByMultipleComponentsProps'); + + expect( + mixinResults.propResultsByName.mapValues((v) => v.totalRate), + allOf( + containsPair('set100percent', 1.0), + containsPair('set80percent', 0.8), + containsPair('set20percent', 0.2), + ), + ); + expect( + mixinResults.propResultsByName.mapValues((v) => v.samePackageRate), + allOf( + containsPair('set100percent', 1.0), + containsPair('set80percent', 1.0), + containsPair('set20percent', 0.5), + ), + ); + expect( + mixinResults.propResultsByName.mapValues((v) => v.otherPackageRate), + allOf( + containsPair('set100percent', 1.0), + containsPair('set80percent', 2 / 3), + containsPair('set20percent', anyOf(null, 0.0)), + ), + ); + expect(mixinResults.usageSkipRate, 0); + }); + + group('skip rate:', () { + test('props that are never skipped', () { + final mixinResults = + aggregated.mixinResultsByName('TestPublicProps'); + expect(mixinResults.usageSkipRate, 0); + expect(mixinResults.usageSkipCount, 0); + }); + }); + }); + + test('does not aggregate data for non-factory usages', () { + final mixinResults = + aggregated.mixinResultsByName('TestPrivateNonFactoryUsagesProps'); + final propTotalRates = + mixinResults.propResultsByName.mapValues((v) => v.totalRate); + expect( + mixinResults.propResultsByName['set100percent'], + isA() + .having((r) => r.totalRate, 'totalRate', 1) + .having((r) => r.totalUsageCount, 'totalUsageCount', 1), + reason: + 'test setup check: should contain data for the single factory-based usage', + ); + expect(propTotalRates.keys.toList(), unorderedEquals(['set100percent']), + reason: + 'should not contain data for non-factory usages and props set on them, such as `onlySetOnNonFactoryUsages`'); + }); + }); + // Use a longer timeout since setupAll can be slow. + }, timeout: Timeout(Duration(seconds: 60))); +} + +Future collectAndAggregateDataForTestPackage() async { + print('Collecting data (this may take a while)...'); + final tmpFolder = + Directory.systemTemp.createTempSync('prop-requiredness-test'); + addTearDown(() => tmpFolder.delete(recursive: true)); + + final orcmRoot = findPackageRootFor(p.current); + final testPackagePath = p.join( + orcmRoot, 'test/test_fixtures/required_props/test_consuming_package'); + + final aggregateOutputFile = File(p.join(tmpFolder.path, 'aggregated.json')); + + await runCommandAndThrowIfFailedInheritIo('dart', [ + 'run', + p.join(orcmRoot, 'bin/null_safety_required_props.dart'), + 'collect', + ...['--output', aggregateOutputFile.path], + testPackagePath, + ]); + + return PropRequirednessResults.fromJson( + jsonDecode(aggregateOutputFile.readAsStringSync())); +} + +extension on PropRequirednessResults { + static const testPackageName = 'test_package'; + + String mixinIdForName(String mixinName) { + return mixinMetadata.mixinNamesById.entries + .singleWhere((entry) => entry.value == mixinName) + .key; + } + + MixinResult mixinResultsByName(String mixinName) { + final mixinId = mixinIdForName(mixinName); + return this.mixinResultsByIdByPackage[testPackageName]![mixinId]!; + } +} + +extension on Map { + /// Returns a new map with values transformed by [convertValue]. + Map mapValues(T convertValue(V value)) => + map((key, value) => MapEntry(key, convertValue(value))); +} diff --git a/test/test_fixtures/required_props/test_consuming_package/lib/src/test_consume_public.dart b/test/test_fixtures/required_props/test_consuming_package/lib/src/test_consume_public.dart new file mode 100644 index 00000000..2a910b56 --- /dev/null +++ b/test/test_fixtures/required_props/test_consuming_package/lib/src/test_consume_public.dart @@ -0,0 +1,8 @@ +import 'package:test_package/entrypoint.dart'; + +usages() { + // 4 usages in source package, 1 in this package + (TestPublic() + ..set100percent = '' + ..set20percent = '')(); +} diff --git a/test/test_fixtures/required_props/test_consuming_package/lib/src/test_consume_public_multiple_components.dart b/test/test_fixtures/required_props/test_consuming_package/lib/src/test_consume_public_multiple_components.dart new file mode 100644 index 00000000..86696072 --- /dev/null +++ b/test/test_fixtures/required_props/test_consuming_package/lib/src/test_consume_public_multiple_components.dart @@ -0,0 +1,22 @@ +import 'package:over_react/over_react.dart'; +import 'package:test_package/entrypoint.dart'; + +class TestMultiComponentsOtherPackageProps = UiProps + with TestPublicUsedByMultipleComponentsProps; + +UiFactory + TestMultiComponentsOtherPackage = uiFunction( + (props) {}, + _$TestMultiComponentsOtherPackageConfig, // ignore: undefined_identifier +); + +usages() { + // 2 usages of mixin in source package, 3 in this package + (TestMultiComponentsOtherPackage() + ..set100percent = '' + ..set80percent = '')(); + (TestMultiComponentsOtherPackage() + ..set100percent = '' + ..set80percent = '')(); + (TestMultiComponentsOtherPackage()..set100percent = '')(); +} diff --git a/test/test_fixtures/required_props/test_consuming_package/pubspec.yaml b/test/test_fixtures/required_props/test_consuming_package/pubspec.yaml new file mode 100644 index 00000000..4ab8af6d --- /dev/null +++ b/test/test_fixtures/required_props/test_consuming_package/pubspec.yaml @@ -0,0 +1,7 @@ +name: test_consuming_package +environment: + sdk: '>=2.11.0 <3.0.0' +dependencies: + over_react: ^5.0.0 + test_package: + path: ../test_package diff --git a/test/test_fixtures/required_props/test_package/lib/entrypoint.dart b/test/test_fixtures/required_props/test_package/lib/entrypoint.dart new file mode 100644 index 00000000..71c5e221 --- /dev/null +++ b/test/test_fixtures/required_props/test_package/lib/entrypoint.dart @@ -0,0 +1,4 @@ +export 'src/test_public.dart'; +export 'src/test_public_dynamic.dart'; +export 'src/test_public_multiple_components.dart'; +export 'src/test_factory_only_exported.dart' show TestFactoryOnlyExported; diff --git a/test/test_fixtures/required_props/test_package/lib/src/test_factory_only_exported.dart b/test/test_fixtures/required_props/test_package/lib/src/test_factory_only_exported.dart new file mode 100644 index 00000000..c9a7e6f5 --- /dev/null +++ b/test/test_fixtures/required_props/test_package/lib/src/test_factory_only_exported.dart @@ -0,0 +1,18 @@ +import 'package:meta/meta.dart'; +import 'package:over_react/over_react.dart'; + +part 'test_factory_only_exported.over_react.g.dart'; + +@internal +mixin TestFactoryOnlyExportedProps on UiProps { + String set100percent; +} + +UiFactory TestFactoryOnlyExported = uiFunction( + (props) {}, + _$TestFactoryOnlyExportedConfig, // ignore: undefined_identifier +); + +usages() { + (TestFactoryOnlyExported()..set100percent = '')(); +} diff --git a/test/test_fixtures/required_props/test_package/lib/src/test_private.dart b/test/test_fixtures/required_props/test_package/lib/src/test_private.dart new file mode 100644 index 00000000..5a0dbd3e --- /dev/null +++ b/test/test_fixtures/required_props/test_package/lib/src/test_private.dart @@ -0,0 +1,32 @@ +import 'package:over_react/over_react.dart'; + +part 'test_private.over_react.g.dart'; + +mixin TestPrivateProps on UiProps { + String set100percent; + String set80percent; + String set20percent; + String set0percent; +} + +UiFactory TestPrivate = uiFunction( + (props) {}, + _$TestPrivateConfig, // ignore: undefined_identifier +); + +usages() { + (TestPrivate() + ..set100percent = '' + ..set80percent = '' + ..set20percent = '')(); + (TestPrivate() + ..set100percent = '' + ..set80percent = '')(); + (TestPrivate() + ..set100percent = '' + ..set80percent = '')(); + (TestPrivate() + ..set100percent = '' + ..set80percent = '')(); + (TestPrivate()..set100percent = '')(); +} diff --git a/test/test_fixtures/required_props/test_package/lib/src/test_private_dynamic.dart b/test/test_fixtures/required_props/test_package/lib/src/test_private_dynamic.dart new file mode 100644 index 00000000..15bc7489 --- /dev/null +++ b/test/test_fixtures/required_props/test_package/lib/src/test_private_dynamic.dart @@ -0,0 +1,66 @@ +import 'package:over_react/over_react.dart'; + +part 'test_private_dynamic.over_react.g.dart'; + +mixin TestPrivateDynamicProps on UiProps { + String set100percent; +} + +UiFactory TestPrivateDynamic = uiFunction( + (props) {}, + _$TestPrivateDynamicConfig, // ignore: undefined_identifier +); + +void dynamicUsages(Map props, void Function(Map) propsModifier) { + // Test all dynamic usage cases. + // 75% of usages are dynamic. + + // One non-dynamic usage to help assert we're collecting data properly. + (TestPrivateDynamic()..set100percent = '')(); + + (TestPrivateDynamic() + ..addProps(props) + ..set100percent = '')(); + (TestPrivateDynamic() + ..addAll(props) + ..set100percent = '')(); + (TestPrivateDynamic() + ..modifyProps(propsModifier) + ..set100percent = '')(); +} + +mixin TestPrivateForwardedProps on UiProps { + String set100percent; +} + +UiFactory TestPrivateForwarded = uiFunction( + (props) {}, + _$TestPrivateForwardedConfig, // ignore: undefined_identifier +); + +abstract class ForwardedUsagesComponent extends UiComponent2 { + void forwardedUsages() { + // Test all forwarded usage cases. + + // One non-dynamic usage to help assert we're collecting data properly. + (TestPrivateForwarded()..set100percent = '')(); + + (TestPrivateForwarded() + ..set100percent = '' + ..addProps(copyUnconsumedProps()))(); + (TestPrivateForwarded() + ..modifyProps(addUnconsumedProps) + ..set100percent = '')(); + + (TestPrivateForwarded() + ..set100percent = '' + ..addProps(props.getPropsToForward()))(); + (TestPrivateForwarded() + ..set100percent = '' + ..modifyProps(props.addPropsToForward()))(); + + (TestPrivateForwarded() + ..addUnconsumedProps(props, []) + ..set100percent = '')(); + } +} diff --git a/test/test_fixtures/required_props/test_package/lib/src/test_private_existing_hints.dart b/test/test_fixtures/required_props/test_package/lib/src/test_private_existing_hints.dart new file mode 100644 index 00000000..cb0ed95b --- /dev/null +++ b/test/test_fixtures/required_props/test_package/lib/src/test_private_existing_hints.dart @@ -0,0 +1,37 @@ +import 'package:over_react/over_react.dart'; + +part 'test_private.over_react.g.dart'; + +mixin TestPrivateExistingHintsProps on UiProps { + String set100percentWithoutHint; + /*late*/ String set100percent; + String/*?*/ set80percent; + String/*?*/ set0percent; +} + +UiFactory TestPrivateExistingHints = uiFunction( + (props) {}, + _$TestPrivateExistingHintsConfig, // ignore: undefined_identifier +); + +usages() { + (TestPrivateExistingHints() + ..set100percentWithoutHint = '' + ..set100percent = '' + ..set80percent = '')(); + (TestPrivateExistingHints() + ..set100percentWithoutHint = '' + ..set100percent = '' + ..set80percent = '')(); + (TestPrivateExistingHints() + ..set100percentWithoutHint = '' + ..set100percent = '' + ..set80percent = '')(); + (TestPrivateExistingHints() + ..set100percentWithoutHint = '' + ..set100percent = '' + ..set80percent = '')(); + (TestPrivateExistingHints() + ..set100percentWithoutHint = '' + ..set100percent = '')(); +} diff --git a/test/test_fixtures/required_props/test_package/lib/src/test_private_multiple_components.dart b/test/test_fixtures/required_props/test_package/lib/src/test_private_multiple_components.dart new file mode 100644 index 00000000..a921d111 --- /dev/null +++ b/test/test_fixtures/required_props/test_package/lib/src/test_private_multiple_components.dart @@ -0,0 +1,42 @@ +import 'package:over_react/over_react.dart'; + +mixin TestPrivateUsedByMultipleComponentsProps on UiProps { + String set100percent; + String set80percent; + String set20percent; + String set0percent; +} + +class TestMultiComponents1Props = UiProps + with TestPrivateUsedByMultipleComponentsProps; + +UiFactory TestMultiComponents1 = uiFunction( + (props) {}, + _$TestMultiComponents1Config, // ignore: undefined_identifier +); + +class TestMultiComponents2Props = UiProps + with TestPrivateUsedByMultipleComponentsProps; + +UiFactory TestMultiComponents2 = uiFunction( + (props) {}, + _$TestMultiComponents2Config, // ignore: undefined_identifier +); + +usages() { + (TestMultiComponents1() + ..set100percent = '' + ..set80percent = '' + ..set20percent = '')(); + (TestMultiComponents1() + ..set100percent = '' + ..set80percent = '')(); + (TestMultiComponents1() + ..set100percent = '' + ..set80percent = '')(); + (TestMultiComponents1() + ..set100percent = '' + ..set80percent = '')(); + + (TestMultiComponents2()..set100percent = '')(); +} diff --git a/test/test_fixtures/required_props/test_package/lib/src/test_private_non_factory_usages.dart b/test/test_fixtures/required_props/test_package/lib/src/test_private_non_factory_usages.dart new file mode 100644 index 00000000..1f9e85af --- /dev/null +++ b/test/test_fixtures/required_props/test_package/lib/src/test_private_non_factory_usages.dart @@ -0,0 +1,40 @@ +import 'package:over_react/over_react.dart'; + +part 'test_private_non_factory_usages.over_react.g.dart'; + +mixin TestPrivateNonFactoryUsagesProps on UiProps { + String set100percent; + String onlySetOnNonFactoryUsages; +} + +UiFactory TestPrivateNonFactoryUsages = + uiFunction( + (props) {}, + _$TestPrivateNonFactoryUsagesConfig, // ignore: undefined_identifier +); + +class SomeObject { + UiFactory factoryProperty; +} + +usages(SomeObject object) { + // A single usage to make sure we're collecting data for these props. + (TestPrivateNonFactoryUsages()..set100percent = '')(); + { + final factoryLocalVariable = TestPrivateNonFactoryUsages; + (factoryLocalVariable() + ..set100percent = '' + ..onlySetOnNonFactoryUsages = '')(); + } + { + final builderLocalVariable = TestPrivateNonFactoryUsages(); + (builderLocalVariable + ..set100percent = '' + ..onlySetOnNonFactoryUsages = '')(); + } + { + (object.factoryProperty() + ..set100percent = '' + ..onlySetOnNonFactoryUsages = '')(); + } +} diff --git a/test/test_fixtures/required_props/test_package/lib/src/test_public.dart b/test/test_fixtures/required_props/test_package/lib/src/test_public.dart new file mode 100644 index 00000000..140a6d68 --- /dev/null +++ b/test/test_fixtures/required_props/test_package/lib/src/test_public.dart @@ -0,0 +1,21 @@ +import 'package:over_react/over_react.dart'; + +part 'test_public.g.dart'; + +mixin TestPublicProps on UiProps { + String set100percent; + String set20percent; +} + +UiFactory TestPublic = uiFunction( + (props) {}, + _$TestPublicConfig, // ignore: undefined_identifier +); + +usages() { + // 4 usages in this package, 1 in consuming package + (TestPublic()..set100percent = '')(); + (TestPublic()..set100percent = '')(); + (TestPublic()..set100percent = '')(); + (TestPublic()..set100percent = '')(); +} diff --git a/test/test_fixtures/required_props/test_package/lib/src/test_public_dynamic.dart b/test/test_fixtures/required_props/test_package/lib/src/test_public_dynamic.dart new file mode 100644 index 00000000..90ff87d9 --- /dev/null +++ b/test/test_fixtures/required_props/test_package/lib/src/test_public_dynamic.dart @@ -0,0 +1,31 @@ +import 'package:over_react/over_react.dart'; + +part 'test_public_dynamic.over_react.g.dart'; + +mixin TestPublicDynamicProps on UiProps { + String set100percent; +} + +UiFactory TestPublicDynamic = uiFunction( + (props) {}, + _$TestPublicDynamicConfig, // ignore: undefined_identifier +); + +void dynamicUsages(Map props) { + // 80% of usages are dynamic. + + // One non-dynamic usage to help assert we're collecting data properly. + (TestPublicDynamic()..set100percent = '')(); + (TestPublicDynamic() + ..addProps(props) + ..set100percent = '')(); + (TestPublicDynamic() + ..addProps(props) + ..set100percent = '')(); + (TestPublicDynamic() + ..addProps(props) + ..set100percent = '')(); + (TestPublicDynamic() + ..addProps(props) + ..set100percent = '')(); +} diff --git a/test/test_fixtures/required_props/test_package/lib/src/test_public_multiple_components.dart b/test/test_fixtures/required_props/test_package/lib/src/test_public_multiple_components.dart new file mode 100644 index 00000000..3faad993 --- /dev/null +++ b/test/test_fixtures/required_props/test_package/lib/src/test_public_multiple_components.dart @@ -0,0 +1,28 @@ +import 'package:over_react/over_react.dart'; + +mixin TestPublicUsedByMultipleComponentsProps on UiProps { + String set100percent; + String set80percent; + String set20percent; + String set0percent; +} + +class _TestMultiComponentsSamePackageProps = UiProps + with TestPublicUsedByMultipleComponentsProps; + +UiFactory<_TestMultiComponentsSamePackageProps> + _TestMultiComponentsSamePackage = uiFunction( + (props) {}, + _$_TestMultiComponentsSamePackageConfig, // ignore: undefined_identifier +); + +usages() { + // 2 usages of mixin in this package, 3 in consuming package + (_TestMultiComponentsSamePackage() + ..set100percent = '' + ..set80percent = '' + ..set20percent = '')(); + (_TestMultiComponentsSamePackage() + ..set100percent = '' + ..set80percent = '')(); +} diff --git a/test/test_fixtures/required_props/test_package/lib/src/test_required_annotations.dart b/test/test_fixtures/required_props/test_package/lib/src/test_required_annotations.dart new file mode 100644 index 00000000..2732408d --- /dev/null +++ b/test/test_fixtures/required_props/test_package/lib/src/test_required_annotations.dart @@ -0,0 +1,36 @@ +import 'package:over_react/over_react.dart'; + +part 'test_required_annotations.over_react.g.dart'; + +mixin TestRequiredAnnotationsProps on UiProps { + @requiredProp + String annotatedRequiredProp; + @nullableRequiredProp + String annotatedNullableRequiredProp; + + @requiredProp + String annotatedRequiredPropSet50Percent; + @requiredProp + String annotatedRequiredPropSet0Percent; + + /// Doc comment + @requiredProp + String annotatedRequiredPropWithDocComment; +} + +UiFactory TestRequiredAnnotations = uiFunction( + (props) {}, + _$TestRequiredAnnotationsConfig, // ignore: undefined_identifier +); + +usages() { + (TestRequiredAnnotations() + ..annotatedRequiredProp = '' + ..annotatedNullableRequiredProp = '' + ..annotatedRequiredPropWithDocComment = '')(); + (TestRequiredAnnotations() + ..annotatedRequiredProp = '' + ..annotatedNullableRequiredProp = '' + ..annotatedRequiredPropWithDocComment = '' + ..annotatedRequiredPropSet50Percent = '')(); +} diff --git a/test/test_fixtures/required_props/test_package/pubspec.yaml b/test/test_fixtures/required_props/test_package/pubspec.yaml new file mode 100644 index 00000000..fca5cced --- /dev/null +++ b/test/test_fixtures/required_props/test_package/pubspec.yaml @@ -0,0 +1,6 @@ +name: test_package +environment: + sdk: '>=2.11.0 <3.0.0' +dependencies: + meta: ^1.3.0 + over_react: ^5.0.0 diff --git a/test/util/args_test.dart b/test/util/args_test.dart new file mode 100644 index 00000000..a2fddc62 --- /dev/null +++ b/test/util/args_test.dart @@ -0,0 +1,112 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:over_react_codemod/src/util/args.dart'; +import 'package:test/test.dart'; + +void main() { + group('argument utilties', () { + void sharedTest({ + required List getArgsToRemove(), + required List removeArgs(List args), + }) { + final args = [ + '--other-option-1=1', + '--other-option-2', + '2', + '--other-flag', + ...getArgsToRemove(), + 'positional' + ]; + final expectedArgs = [ + '--other-option-1=1', + '--other-option-2', + '2', + '--other-flag', + 'positional' + ]; + expect(removeArgs(args), expectedArgs); + } + + group('removeOptionArgs', () { + group('removes options that match the given names', () { + group('when the args use', () { + test('= syntax', () { + sharedTest( + getArgsToRemove: () => ['--test-option=value'], + removeArgs: (args) => removeOptionArgs(args, ['test-option']), + ); + }); + + test('multi-argument syntax', () { + sharedTest( + getArgsToRemove: () => ['--test-option', 'value'], + removeArgs: (args) => removeOptionArgs(args, ['test-option']), + ); + }); + }); + + test('when there are multiple matching options of the same name', () { + sharedTest( + getArgsToRemove: () => + ['--test-option', 'value1', '--test-option', 'value2'], + removeArgs: (args) => removeOptionArgs(args, ['test-option']), + ); + }); + + test('when multiple names are specified', () { + sharedTest( + getArgsToRemove: () => + ['--test-option-1', 'value', '--test-option-2', 'value'], + removeArgs: (args) => + removeOptionArgs(args, ['test-option-1', 'test-option-2']), + ); + }); + }); + }); + + group('removeFlagArgs', () { + group('removes flags that match the given names', () { + test('', () { + sharedTest( + getArgsToRemove: () => ['--test-flag'], + removeArgs: (args) => removeFlagArgs(args, ['test-flag']), + ); + }); + + test('when the flags are inverted', () { + sharedTest( + getArgsToRemove: () => ['--no-test-flag'], + removeArgs: (args) => removeFlagArgs(args, ['test-flag']), + ); + }); + + test('when there are multiple matching flags of the same name', () { + sharedTest( + getArgsToRemove: () => ['--test-flag', '--test-flag'], + removeArgs: (args) => removeFlagArgs(args, ['test-flag']), + ); + }); + + test('when multiple names are specified', () { + sharedTest( + getArgsToRemove: () => ['--test-flag-1', '--test-flag-2'], + removeArgs: (args) => + removeFlagArgs(args, ['test-flag-1', 'test-flag-2']), + ); + }); + }); + }); + }); +} diff --git a/tool/dart_dev/config.dart b/tool/dart_dev/config.dart new file mode 100644 index 00000000..cf617628 --- /dev/null +++ b/tool/dart_dev/config.dart @@ -0,0 +1,9 @@ +import 'package:dart_dev/dart_dev.dart'; +import 'package:glob/glob.dart'; + +final config = { + 'format': FormatTool() + ..exclude = [ + Glob('test/test_fixtures/**'), + ] +};