blob: 5c8019dab4422de2b2ec92ea5b1b6cd1cea44007 [file] [log] [blame]
// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// Command-line tool presenting combined information from dump-info and
/// runtime coverage data.
///
/// This tool requires two input files an `.info.data` and a
/// `.coverage.json` file. To produce these files you need to follow these
/// steps:
///
/// * Compile an app with dart2js using --dump-info and save the .info.data
/// file:
///
/// dart2js --dump-info main.dart
///
/// * Build the same app with dart2js using --experimental-track-allocations:
///
/// dart2js --experimental-track-allocations main.dart
///
/// This can be combined with the --dump-info step above.
///
/// * Load your app, exercise your code, then extract the runtime code
/// coverage JSON blob by querying
/// `$__dart_deferred_initializers__.allocations` in the page.
///
/// * Finally, run this tool.
library compiler.tool.runtime_coverage_analysis;
import 'dart:convert';
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:collection/collection.dart';
import 'package:dart2js_info/info.dart';
import 'package:dart2js_info/src/io.dart';
import 'package:dart2js_info/src/util.dart';
import 'usage_exception.dart';
class RuntimeCoverageAnalysisCommand extends Command<void>
with PrintUsageException {
@override
final String name = "runtime_coverage";
@override
final String description = "Analyze runtime coverage data";
RuntimeCoverageAnalysisCommand() {
argParser.addFlag('show-packages',
defaultsTo: false, help: "Show coverage details at the package level.");
argParser.addOption('class-filter',
defaultsTo: '', help: "Show coverage details filtered by class.");
}
@override
void run() async {
var args = argResults!.rest;
if (args.length < 2) {
usageException('Missing arguments, expected: info.data coverage.json');
}
var showPackages = argResults!['show-packages'] as bool;
var filterFile = argResults!['class-filter'] as String;
if (showPackages && filterFile.isNotEmpty) {
throw StateError('Cannot specify both packages view and filtered view.');
}
if (showPackages) {
await _reportWithPackages(args[0], args[1]);
} else if (filterFile.isNotEmpty) {
_reportWithClassFilter(args[0], args[1], filterFile);
} else {
await _report(args[0], args[1]);
}
}
}
Future<void> _report(
String infoFile,
String coverageFile,
) async {
final info = await infoFromFile(infoFile);
final coverageRaw = jsonDecode(File(coverageFile).readAsStringSync());
// The value associated with each coverage item isn't used for now.
final coverage = coverageRaw.keys.toSet();
// Ensure that a used class's super, mixed in, and implemented classes are
// correctly marked as used.
final seen = <ClassInfo>{};
void collectSupers(ClassInfo c) {
if (seen.contains(c)) return;
seen.add(c);
coverage.add(qualifiedName(c));
c.supers.forEach(collectSupers);
}
for (final c in info.classes) {
if (coverage.contains(qualifiedName(c))) {
c.supers.forEach(collectSupers);
}
}
int totalProgramSize = info.program!.size;
int totalLibSize = info.libraries.fold(0, (n, lib) => n + lib.size);
int totalCode = 0;
int usedCode = 0;
var unused = PriorityQueue<Info>((a, b) => b.size.compareTo(a.size));
void tallyCode(Info i) {
totalCode += i.size;
var name = qualifiedName(i);
var used = coverage.contains(name);
if (used) {
usedCode += i.size;
} else {
unused.add(i);
}
}
info.classes.forEach(tallyCode);
info.closures.forEach(tallyCode);
_section('Runtime Coverage Summary');
_showHeader('', 'bytes', '%');
_show('Program size', totalProgramSize, totalProgramSize);
_show('Libraries (excluding statics)', totalLibSize, totalProgramSize);
_show('Code (classes + closures)', totalCode, totalProgramSize);
_show('Used', usedCode, totalProgramSize);
print('');
_showHeader('', 'count', '%');
var total = info.classes.length + info.closures.length;
_show('Classes + closures', total, total);
_show('Used', total - unused.length, total);
print('');
var unusedTotal = totalCode - usedCode;
_section('Runtime Coverage Breakdown', size: unusedTotal);
for (int i = 0; i < unused.length; i++) {
var item = unused.removeFirst();
var percent = (item.size * 100 / unusedTotal).toStringAsFixed(2);
print('${qualifiedName(item)}: ${item.size} bytes, $percent%');
}
}
/// Generates a report aggregated at the package level.
Future<void> _reportWithPackages(
String infoFile,
String coverageFile,
) async {
final info = await infoFromFile(infoFile);
final coverageRaw = jsonDecode(File(coverageFile).readAsStringSync());
// The value associated with each coverage item isn't used for now.
final coverage = coverageRaw.keys.toSet();
// Ensure that a used class's super, mixed in, and implemented classes are
// correctly marked as used.
final seen = <ClassInfo>{};
void collectSupers(ClassInfo c) {
if (seen.contains(c)) return;
seen.add(c);
coverage.add(qualifiedName(c));
c.supers.forEach(collectSupers);
}
for (final c in info.classes) {
if (coverage.contains(qualifiedName(c))) {
c.supers.forEach(collectSupers);
}
}
int totalProgramSize = info.program!.size;
int totalLibSize = info.libraries.fold(0, (n, lib) => n + lib.size);
int totalCode = 0;
int usedCode = 0;
var packageData = <String, RuntimePackageInfo>{};
var unused = PriorityQueue<Info>((a, b) => b.size.compareTo(a.size));
void tallyCode(BasicInfo i) {
totalCode += i.size;
var name = qualifiedName(i);
var used = coverage.contains(name);
var groupName = libraryGroupName(i);
packageData.putIfAbsent(groupName!, () => RuntimePackageInfo());
packageData[groupName]!.add(i, used: used);
if (used) {
usedCode += i.size;
} else {
unused.add(i);
}
}
info.classes.forEach(tallyCode);
info.closures.forEach(tallyCode);
_section('Runtime Coverage Summary');
_showHeader('', 'bytes', '%');
_show('Program size', totalProgramSize, totalProgramSize);
_show('Libraries (excluding statics)', totalLibSize, totalProgramSize);
_show('Code (classes + closures)', totalCode, totalProgramSize);
_show('Used', usedCode, totalProgramSize);
print('');
_showHeader('', 'count', '%');
var total = info.classes.length + info.closures.length;
_show('Classes + closures', total, total);
_show('Used', total - unused.length, total);
print('');
var unusedTotal = totalCode - usedCode;
_section('Runtime Coverage Breakdown (packages)', size: unusedTotal);
for (var entry in packageData.entries.sortedBy((e) => -e.value.unusedSize)) {
var packageLabel = entry.key;
var packageInfo = entry.value;
print(' $packageLabel (${packageInfo.unusedSize} bytes unused)');
var packageRatioString = (packageInfo.usedRatio * 100).toStringAsFixed(2);
_leftPadded(
' proportion of package used:',
'${packageInfo.usedSize}/${packageInfo.totalSize} '
'($packageRatioString%)');
var codeRatioString =
(packageInfo.unusedSize / totalCode * 100).toStringAsFixed(2);
_leftPadded(' proportion of unused code to all code:',
'${packageInfo.unusedSize}/$totalCode ($codeRatioString%)');
var unusedCodeRatioString =
(packageInfo.unusedSize / unusedTotal * 100).toStringAsFixed(2);
_leftPadded(' proportion of unused code to all unused code:',
'${packageInfo.unusedSize}/$unusedTotal ($unusedCodeRatioString%)');
var mainUnitPackageRatioString =
(packageInfo.mainUnitSize / packageInfo.totalSize * 100)
.toStringAsFixed(2);
_leftPadded(
' proportion of main unit code to package code:',
'${packageInfo.mainUnitSize}/${packageInfo.totalSize} '
'($mainUnitPackageRatioString%)');
var unusedMainUnitRatioString =
(packageInfo.unusedMainUnitSize / packageInfo.mainUnitSize * 100)
.toStringAsFixed(2);
_leftPadded(
' proportion of main unit code that is unused:',
'${packageInfo.unusedMainUnitSize}/${packageInfo.mainUnitSize} '
'($unusedMainUnitRatioString%)');
print(' package breakdown:');
for (var item in packageInfo.elements.toList()) {
var percent =
(item.size * 100 / packageInfo.totalSize).toStringAsFixed(2);
var name = qualifiedName(item);
var used = coverage.contains(name);
var usedTick = used ? '+' : '-';
var mainUnitTick = item.outputUnit!.name == 'main' ? 'M' : 'D';
_leftPadded(' [$usedTick$mainUnitTick] ${qualifiedName(item)}:',
'${item.size} bytes ($percent% of package)');
}
print('');
}
}
/// Generates a report filtered by class.
Future<void> _reportWithClassFilter(
String infoFile, String coverageFile, String filterFile,
{bool showUncategorizedClasses = false}) async {
final info = await infoFromFile(infoFile);
final coverageRaw = jsonDecode(File(coverageFile).readAsStringSync());
// The value associated with each coverage item isn't used for now.
Set<String> coverage = coverageRaw.keys.toSet();
final classFilterData = <String, RuntimeClassInfo>{};
for (final runtimeClassInfo in File(filterFile)
.readAsLinesSync()
.map((l) => RuntimeClassInfo.fromAngularInfo(l))) {
classFilterData[runtimeClassInfo.key] = runtimeClassInfo;
}
// Ensure that a used class's super, mixed in, and implemented classes are
// correctly marked as used.
final seen = <ClassInfo>{};
void collectSupers(ClassInfo c) {
if (seen.contains(c)) return;
seen.add(c);
coverage.add(qualifiedName(c));
c.supers.forEach(collectSupers);
}
for (final c in info.classes) {
if (coverage.contains(qualifiedName(c))) {
c.supers.forEach(collectSupers);
}
}
int totalProgramSize = info.program!.size;
int totalLibSize = info.libraries.fold(0, (n, lib) => n + lib.size);
int usedCode = 0;
int filterTotalCode = 0;
int filterUsedCode = 0;
int usedProcessedCode = 0;
final uncategorizedClasses = <ClassInfo>{};
final categorizedClasses = <ClassInfo>{};
void processInfoForClass(ClassInfo info) {
final name = qualifiedName(info);
final used = coverage.contains(name);
final nameWithoutPrefix =
name.substring(name.indexOf(':') + 1, name.length);
if (used) {
usedCode += info.size;
}
final runtimeClassInfo = classFilterData[nameWithoutPrefix];
if (runtimeClassInfo == null) {
uncategorizedClasses.add(info);
return;
}
if (categorizedClasses.contains(info)) {
runtimeClassInfo.annotateWithClassInfo(info, used: used);
return;
}
categorizedClasses.add(info);
runtimeClassInfo.annotateWithClassInfo(info, used: used);
filterTotalCode += info.size;
if (used) {
usedProcessedCode += 1;
filterUsedCode += info.size;
}
}
info.classes.forEach(processInfoForClass);
int totalCode = 0;
for (final closure in info.closures) {
totalCode += closure.size;
final name = qualifiedName(closure);
final used = coverage.contains(name);
if (used) {
usedCode += closure.size;
}
}
for (final classInfo in uncategorizedClasses) {
totalCode += classInfo.size;
final name = qualifiedName(classInfo);
final used = coverage.contains(name);
if (used) {
usedCode += classInfo.size;
}
}
for (final classInfo in categorizedClasses) {
totalCode += classInfo.size;
final name = qualifiedName(classInfo);
final used = coverage.contains(name);
if (used) {
usedCode += classInfo.size;
}
}
_section('Runtime Coverage Summary');
_showHeader('', 'bytes', '%');
_show('Program size', totalProgramSize, totalProgramSize);
_show('Libraries (excluding statics)', totalLibSize, totalProgramSize);
_show('Code (classes + closures)', totalCode, totalProgramSize);
_show('Used', usedCode, totalProgramSize);
print('');
final unusedTotal = totalCode - usedCode;
_section('Runtime Coverage Breakdown (filtered)', size: unusedTotal);
print('Filtered Breakdown:');
print('Total (count): ${categorizedClasses.length}');
print('Used (count): $usedProcessedCode '
'(${usedProcessedCode / categorizedClasses.length * 100}%)');
print('Total (bytes): $filterTotalCode');
print('Used (bytes): $filterUsedCode '
'(${filterUsedCode / filterTotalCode * 100}%)');
for (final runtimeClassInfo in classFilterData.values
.sortedBy((v) => v.annotated ? (v.used ? v.size : -v.size) : 0)) {
if (!runtimeClassInfo.annotated) continue;
final classInfo = runtimeClassInfo.info;
final percent = (classInfo.size * 100 / filterTotalCode).toStringAsFixed(2);
final name = qualifiedName(classInfo);
final used = coverage.contains(name);
final usedTick = used ? '+' : '-';
final mainUnitTick = classInfo.outputUnit?.name == 'main' ? 'M' : 'D';
_leftPadded(' [$usedTick$mainUnitTick] ${qualifiedName(classInfo)}:',
'${classInfo.size} bytes ($percent% of filtered items)');
}
print('');
print('Unaccounted classes in filter:');
for (final runtimeClassInfo
in classFilterData.values.where((v) => !v.annotated)) {
print(' ${runtimeClassInfo.key}');
}
if (showUncategorizedClasses) {
int uncategorizedSize = 0;
for (final info in uncategorizedClasses) {
uncategorizedSize += info.size;
}
_section('Uncategorized Info', size: uncategorizedSize);
for (var info in uncategorizedClasses) {
final percent = (info.size * 100 / totalProgramSize).toStringAsFixed(2);
final name = qualifiedName(info);
final used = coverage.contains(name);
final usedTick = used ? '+' : '-';
final mainUnitTick = info.outputUnit?.name == 'main' ? 'M' : 'D';
_leftPadded(' [$usedTick$mainUnitTick] $name:',
'${info.size} bytes ($percent% of program)');
}
}
print('');
}
void _section(String title, {int? size}) {
if (size == null) {
print(title);
} else {
print('$title ($size bytes)');
}
print('=' * 72);
}
_showHeader(String msg, String header1, String header2) {
print(' ${pad(msg, 30, right: true)} ${pad(header1, 8)} ${pad(header2, 6)}');
}
_show(String msg, int size, int total) {
var percent = (size * 100 / total).toStringAsFixed(2);
print(' ${pad(msg, 30, right: true)} ${pad(size, 8)} ${pad(percent, 6)}%');
}
_leftPadded(String msg1, String msg2) {
print(' ${pad(msg1, 50, right: true)} $msg2');
}
class RuntimePackageInfo {
final elements = PriorityQueue<BasicInfo>((a, b) => b.size.compareTo(a.size));
num mainUnitSize = 0;
num totalSize = 0;
num unusedMainUnitSize = 0;
num unusedSize = 0;
num usedRatio = 0;
num usedSize = 0;
RuntimePackageInfo();
void add(BasicInfo i, {bool used = true}) {
totalSize += i.size;
if (used) {
usedSize += i.size;
} else {
unusedSize += i.size;
}
if (i.outputUnit!.name == 'main') {
mainUnitSize += i.size;
if (!used) {
unusedMainUnitSize += i.size;
}
}
elements.add(i);
usedRatio = usedSize / totalSize;
}
}
class RuntimeClassInfo {
late String scheme;
late String package;
late String? path;
late String name;
late num size;
late bool used;
late bool inMainUnit;
late ClassInfo info;
bool annotated = false;
RuntimeClassInfo();
/// Ingests the output from Angular's info generator.
///
/// Example: 'fully:qualified/path/to/file.dart - ClassName'
RuntimeClassInfo.fromAngularInfo(String input) {
final colonIndex = input.indexOf(':');
if (colonIndex < 0) {
throw ArgumentError('AngularInfo format cannot accept undefined schemes.'
' No scheme found for: $input');
}
final slashIndex = input.indexOf('/');
final spaceIndex = input.indexOf(' ');
final separatorSize = ' - '.length;
scheme = input.substring(0, colonIndex);
if (slashIndex < 0) {
path = null;
package = input.substring(colonIndex + 1, spaceIndex);
} else {
package = input.substring(colonIndex + 1, slashIndex);
path = input.substring(slashIndex + 1, spaceIndex);
}
name = input.substring(spaceIndex + separatorSize, input.length);
}
String get key =>
'$package${path == null ? '' : '/$path'}:$name'.replaceAll('/lib/', '/');
void annotateWithClassInfo(ClassInfo i, {bool used = true}) {
size = i.size;
this.used = used;
inMainUnit = i.outputUnit!.name == 'main';
info = i;
annotated = true;
}
@override
String toString() {
return '$package/$path - $name';
}
}