technical_debt__cost.dart 7.19 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6 7 8
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:convert';
import 'dart:io';

import 'package:flutter_devicelab/framework/framework.dart';
9
import 'package:flutter_devicelab/framework/task_result.dart';
10 11
import 'package:flutter_devicelab/framework/utils.dart';
import 'package:path/path.dart' as path;
12 13
import 'package:pub_semver/pub_semver.dart';
import 'package:pubspec_parse/pubspec_parse.dart';
14

15
// the numbers below are prime, so that the totals don't seem round. :-)
16 17
const double todoCost = 1009.0; // about two average SWE days, in dollars
const double ignoreCost = 2003.0; // four average SWE days, in dollars
18 19
const double pythonCost = 3001.0; // six average SWE days, in dollars
const double skipCost = 2473.0; // 20 hours: 5 to fix the issue we're ignoring, 15 to fix the bugs we missed because the test was off
20
const double ignoreForFileCost = 2477.0; // similar thinking as skipCost
21 22
const double asDynamicCost = 2011.0; // a few days to refactor the code.
const double deprecationCost = 233.0; // a few hours to remove the old code.
23
const double legacyDeprecationCost = 9973.0; // a couple of weeks.
24 25
const double packageNullSafetyMigrationCost = 2017.0; // a few days to migrate package.
const double fileNullSafetyMigrationCost = 257.0; // a few hours to migrate file.
26

27 28 29
final RegExp todoPattern = RegExp(r'(?://|#) *TODO');
final RegExp ignorePattern = RegExp(r'// *ignore:');
final RegExp ignoreForFilePattern = RegExp(r'// *ignore_for_file:');
30 31
final RegExp asDynamicPattern = RegExp(r'\bas dynamic\b');
final RegExp deprecationPattern = RegExp(r'^ *@[dD]eprecated');
32
const Pattern globalsPattern = 'globals.';
33
const String legacyDeprecationPattern = '// flutter_ignore: deprecation_syntax, https';
34 35 36
final RegExp dartVersionPattern = RegExp(r'// *@dart *= *(\d+).(\d+)');

final Version firstNullSafeDartVersion = Version(2, 12, 0);
37

38
Future<double> findCostsForFile(File file) async {
39
  if (path.extension(file.path) == '.py')
40
    return pythonCost;
41 42 43
  if (path.extension(file.path) != '.dart' &&
      path.extension(file.path) != '.yaml' &&
      path.extension(file.path) != '.sh')
44
    return 0.0;
45
  final bool isTest = file.path.endsWith('_test.dart');
46
  final bool isDart = file.path.endsWith('.dart');
47
  double total = 0.0;
48
  for (final String line in await file.readAsLines()) {
49
    if (line.contains(todoPattern))
50
      total += todoCost;
51
    if (line.contains(ignorePattern))
52
      total += ignoreCost;
53 54
    if (line.contains(ignoreForFilePattern))
      total += ignoreForFileCost;
55
    if (!isTest && line.contains(asDynamicPattern))
56
      total += asDynamicCost;
57 58
    if (line.contains(deprecationPattern))
      total += deprecationCost;
59 60
    if (line.contains(legacyDeprecationPattern))
      total += legacyDeprecationCost;
61
    if (isTest && line.contains('skip:') && !line.contains('[intended]'))
62
      total += skipCost;
63 64
    if (isDart && isOptingOutOfNullSafety(line))
      total += fileNullSafetyMigrationCost;
65
  }
66 67
  if (path.basename(file.path) == 'pubspec.yaml' && !packageIsNullSafe(file))
    total += packageNullSafetyMigrationCost;
68
  return total;
69 70
}

71
bool isOptingOutOfNullSafety(String line) {
72
  final RegExpMatch? match = dartVersionPattern.firstMatch(line);
73 74 75 76
  if (match == null) {
    return false;
  }
  assert(match.groupCount == 2);
77
  return Version(int.parse(match.group(1)!), int.parse(match.group(2)!), 0) < firstNullSafeDartVersion;
78 79 80 81 82
}

bool packageIsNullSafe(File file) {
  assert(path.basename(file.path) == 'pubspec.yaml');
  final Pubspec pubspec = Pubspec.parse(file.readAsStringSync());
83
  final VersionConstraint? constraint = pubspec.environment == null ? null : pubspec.environment!['sdk'];
84 85 86 87
  final bool hasConstraint = constraint != null && !constraint.isAny && !constraint.isEmpty;
  return hasConstraint &&
      constraint is VersionRange &&
      constraint.min != null &&
88
      Version(constraint.min!.major, constraint.min!.minor, 0) >= firstNullSafeDartVersion;
89 90
}

91 92 93 94
Future<int> findGlobalsForFile(File file) async {
  if (path.extension(file.path) != '.dart')
    return 0;
  int total = 0;
95
  for (final String line in await file.readAsLines()) {
96 97 98 99 100 101
    if (line.contains(globalsPattern))
      total += 1;
  }
  return total;
}

102 103 104 105 106 107 108
Future<double> findCostsForRepo() async {
  final Process git = await startProcess(
    'git',
    <String>['ls-files', '--full-name', flutterDirectory.path],
    workingDirectory: flutterDirectory.path,
  );
  double total = 0.0;
109
  await for (final String entry in git.stdout.transform<String>(utf8.decoder).transform<String>(const LineSplitter()))
110
    total += await findCostsForFile(File(path.join(flutterDirectory.path, entry)));
111 112
  final int gitExitCode = await git.exitCode;
  if (gitExitCode != 0)
113
    throw Exception('git exit with unexpected error code $gitExitCode');
114 115 116
  return total;
}

117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153
Future<int> findGlobalsForTool() async {
  final Process git = await startProcess(
    'git',
    <String>['ls-files', '--full-name', path.join(flutterDirectory.path, 'packages', 'flutter_tools')],
    workingDirectory: flutterDirectory.path,
  );
  int total = 0;
  await for (final String entry in git.stdout.transform<String>(utf8.decoder).transform<String>(const LineSplitter()))
    total += await findGlobalsForFile(File(path.join(flutterDirectory.path, entry)));
  final int gitExitCode = await git.exitCode;
  if (gitExitCode != 0)
    throw Exception('git exit with unexpected error code $gitExitCode');
  return total;
}

Future<int> countDependencies() async {
  final List<String> lines = (await evalFlutter(
    'update-packages',
    options: <String>['--transitive-closure'],
  )).split('\n');
  final int count = lines.where((String line) => line.contains('->')).length;
  if (count < 2) // we'll always have flutter and flutter_test, at least...
    throw Exception('"flutter update-packages --transitive-closure" returned bogus output:\n${lines.join("\n")}');
  return count;
}

Future<int> countConsumerDependencies() async {
  final List<String> lines = (await evalFlutter(
    'update-packages',
    options: <String>['--transitive-closure', '--consumer-only'],
  )).split('\n');
  final int count = lines.where((String line) => line.contains('->')).length;
  if (count < 2) // we'll always have flutter and flutter_test, at least...
    throw Exception('"flutter update-packages --transitive-closure" returned bogus output:\n${lines.join("\n")}');
  return count;
}

154 155
const String _kCostBenchmarkKey = 'technical_debt_in_dollars';
const String _kNumberOfDependenciesKey = 'dependencies_count';
156
const String _kNumberOfConsumerDependenciesKey = 'consumer_dependencies_count';
157
const String _kNumberOfFlutterToolGlobals = 'flutter_tool_globals_count';
158

159
Future<void> main() async {
160
  await task(() async {
161
    return TaskResult.success(
162 163
      <String, dynamic>{
        _kCostBenchmarkKey: await findCostsForRepo(),
164 165 166
        _kNumberOfDependenciesKey: await countDependencies(),
        _kNumberOfConsumerDependenciesKey: await countConsumerDependencies(),
        _kNumberOfFlutterToolGlobals: await findGlobalsForTool(),
167 168 169 170
      },
      benchmarkScoreKeys: <String>[
        _kCostBenchmarkKey,
        _kNumberOfDependenciesKey,
171
        _kNumberOfConsumerDependenciesKey,
172
        _kNumberOfFlutterToolGlobals,
173
      ],
174 175 176
    );
  });
}