blob: 9bb4a9ca8934bd33da0b1f54f16c6a28fa27851a [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.
// ignore_for_file: avoid_dynamic_calls
/// 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;
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 'package:dart2js_info/src/runtime_coverage_utils.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) {
await _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 = {
for (final info in runtimeInfoFromAngularInfo(filterFile)) info.key: info,
};
// 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,
);
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);
}
void _showHeader(String msg, String header1, String header2) {
print(' ${pad(msg, 30, right: true)} ${pad(header1, 8)} ${pad(header2, 6)}');
}
void _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)}%');
}
void _leftPadded(String msg1, String msg2) {
print(' ${pad(msg1, 50, right: true)} $msg2');
}