execute method

Future<Map<String, dynamic>> execute({
  1. required String projectPath,
  2. required String packageName,
  3. required String targetVersion,
  4. String analysisDepth = 'file_level',
  5. bool includeCascading = true,
})

Execute the analyze_upgrade_impact tool.

Required parameters:

  • projectPath: absolute path to the Flutter/Dart project root.
  • packageName: the package to analyze.
  • targetVersion: the version to upgrade to.

Optional parameters:

  • analysisDepth: "summary", "file_level" (default), or "line_level".
  • includeCascading: whether to check for cascading dependency impacts (default true).

Returns the AnalysisResult serialised as a JSON-compatible map.

Implementation

Future<Map<String, dynamic>> execute({
  required String projectPath,
  required String packageName,
  required String targetVersion,
  String analysisDepth = 'file_level',
  bool includeCascading = true,
}) async {
  Logger.info(
    'Analyzing upgrade impact: $packageName -> $targetVersion '
    '($analysisDepth)',
  );

  // -------------------------------------------------------------------------
  // Step 1: Resolve the current version from pubspec / lockfile.
  // -------------------------------------------------------------------------
  final pubspec = await _pubspecParser.parse(projectPath);
  final currentDep = pubspec.dependencies[packageName] ??
      pubspec.devDependencies[packageName];
  final currentVersion = currentDep?.resolvedVersion;

  if (currentVersion == null) {
    throw ArgumentError(
      'Package $packageName not found in project or has no resolved version',
    );
  }

  Logger.info(
    'Current version of $packageName: $currentVersion -> $targetVersion',
  );

  final warnings = <String>[];
  final isMajor = _versionResolver.isMajorBump(currentVersion, targetVersion);
  if (isMajor) {
    warnings.add(
      'This is a major version bump ($currentVersion -> $targetVersion). '
      'Breaking changes are likely.',
    );
  }

  // -------------------------------------------------------------------------
  // Step 2: Fetch breaking-change data from all sources IN PARALLEL.
  // -------------------------------------------------------------------------
  final isFlutterSdk = packageName == 'flutter' ||
      packageName == 'flutter_sdk' ||
      currentDep?.source == 'sdk';

  // Launch all independent data fetches concurrently.
  final changelogEntriesFuture = _fetchChangelogEntries(
    packageName,
    currentVersion,
    targetVersion,
  );
  final releaseBreakingChangesFuture = _fetchReleaseBreakingChanges(
    packageName,
    currentVersion,
    targetVersion,
  );
  final issueBreakingChangesFuture = _fetchIssueBreakingChanges(
    packageName,
  );
  final flutterDocsChangesFuture = isFlutterSdk
      ? _flutterDocs.getBreakingChangesForVersionRange(
          currentVersion,
          targetVersion,
        )
      : Future.value(<Map<String, dynamic>>[]);
  final versionsFuture = _pubApi.getVersions(packageName);

  // Await all results together.
  final results = await Future.wait([
    changelogEntriesFuture, // [0] List<ChangelogEntry>
    releaseBreakingChangesFuture, // [1] List<BreakingChange>
    issueBreakingChangesFuture, // [2] List<BreakingChange>
    flutterDocsChangesFuture, // [3] List<Map<String, dynamic>>
    versionsFuture, // [4] List<String>
  ]);

  final changelogEntries = results[0] as List<ChangelogEntry>;
  final releaseBreakingChanges = results[1] as List<BreakingChange>;
  final issueBreakingChanges = results[2] as List<BreakingChange>;
  final flutterDocsChanges = results[3] as List<Map<String, dynamic>>;
  final allVersions = results[4] as List<String>;

  // Log version range information.
  final versionsInRange = _versionResolver.getVersionsInRange(
    allVersions,
    currentVersion,
    targetVersion,
  );
  Logger.info(
    'Found ${versionsInRange.length} version(s) between '
    '$currentVersion and $targetVersion',
  );

  // -------------------------------------------------------------------------
  // Step 2b: Merge and deduplicate breaking changes from all sources.
  // -------------------------------------------------------------------------
  final allBreakingChanges = <BreakingChange>[];

  // From CHANGELOG entries.
  for (final entry in changelogEntries) {
    allBreakingChanges.addAll(entry.breakingChanges);
  }

  // From GitHub releases.
  allBreakingChanges.addAll(releaseBreakingChanges);

  // From GitHub issues.
  allBreakingChanges.addAll(issueBreakingChanges);

  // From Flutter docs (convert maps to BreakingChange objects).
  for (final doc in flutterDocsChanges) {
    allBreakingChanges.add(_flutterDocToBreakingChange(doc));
  }

  // Deduplicate by affectedApi (prefer higher-confidence entries).
  final deduplicated = _deduplicateBreakingChanges(allBreakingChanges);

  Logger.info(
    'Collected ${allBreakingChanges.length} breaking change(s), '
    '${deduplicated.length} after deduplication',
  );

  if (deduplicated.isEmpty && isMajor) {
    warnings.add(
      'No breaking changes detected for a major version bump. '
      'This may indicate incomplete changelog data.',
    );
  }

  // -------------------------------------------------------------------------
  // Step 3: AST-based codebase analysis (depth-dependent).
  // -------------------------------------------------------------------------
  var totalFilesAffected = 0;
  var totalLocationsAffected = 0;
  var impacts = <BreakingChangeImpact>[];

  if (analysisDepth == 'summary') {
    // Summary mode: just count files importing the package; skip AST.
    totalFilesAffected = await _codebaseAnalyzer.countImportingFiles(
      projectPath,
      packageName,
    );
    Logger.info(
      'Summary mode: $totalFilesAffected file(s) import $packageName',
    );

    // Create impacts with empty locations for each breaking change.
    impacts = deduplicated
        .map((bc) => BreakingChangeImpact(
              breakingChange: bc,
              affectedLocations: const [],
              suggestedFix: _buildSuggestedFix(bc),
            ))
        .toList();

    // In summary mode, totalLocationsAffected is estimated as
    // totalFilesAffected (at least one usage per importing file).
    totalLocationsAffected = totalFilesAffected;
  } else {
    // file_level or line_level: use CodebaseAnalyzer.searchApiUsages
    // to find exact usages of affected APIs.
    final resolveTypes = analysisDepth == 'line_level';

    // Extract all affected API names from breaking changes.
    final affectedApis = _extractAffectedApis(deduplicated);

    if (affectedApis.isNotEmpty) {
      Logger.info(
        'Searching for ${affectedApis.length} affected API(s) '
        'in codebase (resolve=$resolveTypes)',
      );

      final usageResults = await _codebaseAnalyzer.searchApiUsages(
        projectPath: projectPath,
        apis: affectedApis,
        packageFilter: packageName,
        resolveTypes: resolveTypes,
      );

      // Build BreakingChangeImpact list mapping each breaking change to
      // the code locations that use its affected API.
      impacts = _buildImpacts(deduplicated, usageResults);

      // Compute totals.
      final allAffectedFiles = <String>{};
      for (final impact in impacts) {
        for (final loc in impact.affectedLocations) {
          allAffectedFiles.add(loc.filePath);
          totalLocationsAffected++;
        }
      }
      totalFilesAffected = allAffectedFiles.length;
    } else {
      // No affected APIs known, but we still know files that import
      // the package.
      totalFilesAffected = await _codebaseAnalyzer.countImportingFiles(
        projectPath,
        packageName,
      );
      impacts = deduplicated
          .map((bc) => BreakingChangeImpact(
                breakingChange: bc,
                affectedLocations: const [],
                suggestedFix: _buildSuggestedFix(bc),
              ))
          .toList();

      if (deduplicated.isNotEmpty) {
        warnings.add(
          'Breaking changes were detected but none specify affected API '
          'names. Could not perform targeted codebase search. '
          '$totalFilesAffected file(s) import this package.',
        );
      }
    }

    Logger.info(
      'Codebase analysis: $totalFilesAffected file(s) affected, '
      '$totalLocationsAffected location(s)',
    );
  }

  // -------------------------------------------------------------------------
  // Step 4: Cascade analysis (if requested).
  // -------------------------------------------------------------------------
  var cascadingImpacts = <CascadingImpact>[];

  if (includeCascading) {
    try {
      cascadingImpacts = await _cascadeResolver.resolve(
        packageName: packageName,
        targetVersion: targetVersion,
        currentPubspec: pubspec,
      );
      if (cascadingImpacts.isNotEmpty) {
        Logger.info(
          'Found ${cascadingImpacts.length} cascading impact(s)',
        );
        warnings.add(
          '${cascadingImpacts.length} cascading dependency conflict(s) '
          'detected. Other packages may need to be upgraded simultaneously.',
        );
      }
    } catch (e) {
      Logger.warn('Cascade analysis failed: $e');
      warnings.add('Cascade analysis failed: $e');
    }
  }

  // -------------------------------------------------------------------------
  // Step 5: Risk assessment.
  // -------------------------------------------------------------------------
  final overallConfidence = _calculateOverallConfidence(deduplicated);
  final riskScore = _calculateRiskScore(
    impacts,
    totalFilesAffected,
    overallConfidence,
  );
  // Boost risk for cascading impacts.
  final adjustedScore = cascadingImpacts.isNotEmpty
      ? math.min(10.0, riskScore + cascadingImpacts.length * 0.5)
      : riskScore;
  final adjustedLevel = _riskLevelFromScore(adjustedScore);

  Logger.info(
    'Risk assessment: score=$adjustedScore, level=${adjustedLevel.name}, '
    'confidence=$overallConfidence',
  );

  // -------------------------------------------------------------------------
  // Build and return the result.
  // -------------------------------------------------------------------------
  final result = AnalysisResult(
    packageName: packageName,
    currentVersion: currentVersion,
    targetVersion: targetVersion,
    riskLevel: adjustedLevel,
    riskScore: adjustedScore,
    totalFilesAffected: totalFilesAffected,
    totalLocationsAffected: totalLocationsAffected,
    impacts: impacts,
    cascadingImpacts: cascadingImpacts,
    warnings: warnings,
    overallConfidence: overallConfidence,
  );

  return result.toJson();
}