blob: 3b62149977f27b87777bb6d001580cebb16399a8 [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2025 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Prints out a list of targets that needs to be null-annotated.
By default the list of targets is sorted by the number of deps that a target has
that is not fully annotated. That is, a target whose deps are all annotated is
shown first, followed by targets with one dep that needs annotation, etc.
"""
import argparse
import collections
import json
import pathlib
import re
import subprocess
import sys
_SRC = pathlib.Path(__file__).parents[3].expanduser().resolve()
_DEP_ANALYSIS_DIR = _SRC / 'tools/android/dependency_analysis'
if str(_DEP_ANALYSIS_DIR) not in sys.path:
sys.path.insert(0, str(_DEP_ANALYSIS_DIR))
from print_class_dependencies import serialization
import target_dependency
_DEPENDENCY_JSON_PATH = '/tmp/class_and_target_stats_deps.json'
_NOMARK_LIST_PATH = pathlib.Path('/tmp/java_file_stats_file_nomark')
_FILE_CACHE_PATH = pathlib.Path('/tmp/java_file_stats_file_cache')
_CHROME_PACKAGE_PREFIXES = ('org.chromium', 'com.google')
# These are classes that show up as unmarked but are not really (e.g.
# annotations).
_IGNORED_CLASSES_RE = [
# These are just annotations in build_java.
r'org.chromium.build.annotations.*',
# Errorprone plugins don't need annotations.
r'org.chromium.tools.errorprone.plugin.*',
# Not sure why this one isn't annotated.
r'org.chromium.base.test.ClangProfiler',
# The target boundary_interface_java sets chromium_code=false in
# //android_webview/support_library/boundary_interfaces/BUILD.gn.
r'org.chromium.support_lib_boundary.*',
]
def gen_build_target_graph(out_dir: pathlib.Path, include_testonly: bool,
no_cache: bool):
project_json = out_dir / 'project.json'
if not project_json.exists() or no_cache:
subprocess.run(['gn', 'gen', '--ide=json', str(out_dir)])
assert project_json.exists()
with project_json.open() as f:
data = json.load(f)
outbound = collections.defaultdict(set)
inbound = collections.defaultdict(set)
skip = set()
if not include_testonly:
for target, info in data['targets'].items():
if not info['testonly']:
continue
# If it exists, it should always be True.
assert info['testonly'], f'{target} has false testonly value.'
skip.add(target)
for target, info in data['targets'].items():
if target in skip:
continue
if '__' in target:
target = target.split('__')[0]
for dep in info['deps']:
if '__' in dep:
dep = dep.split('__')[0]
if dep in skip:
continue
# After removing subtargets, A_java depends on A_java__header, but
# we don't want that in the graph.
if target == dep:
continue
outbound[target].add(dep)
inbound[dep].add(target)
return outbound, inbound, skip
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
'-a',
'--all',
action='store_true',
help='Show all targets, by default only those with all deps annotated '
'are shown.')
parser.add_argument(
'--no-cache',
action='store_true',
help='Skip the /tmp caches and regenerate project.json, useful when '
'the list files changes.')
parser.add_argument(
'-C',
'--out-dir',
default='out/Debug',
type=pathlib.Path,
help='Used to generate project.json in the outdir and use it for the '
'target graph. Defaults to out/Debug.')
parser.add_argument(
'-v',
'--verbose',
action='count',
default=0,
help='1 to print deps/dependent count, 2 to print each unmarked class, '
'3 to print lists of deps, and 4 to print lists of dependents.')
parser.add_argument(
'--include-testonly',
action='store_true',
help='Include testonly targets, otherwise excluded by default.')
args = parser.parse_args()
gsutil_cmd = [
'gsutil.py', 'cp', 'gs://clank-dependency-graphs/latest/all.json',
_DEPENDENCY_JSON_PATH
]
subprocess.run(gsutil_cmd, capture_output=True)
class_graph, _, _ = serialization.load_class_and_package_graphs_from_file(
_DEPENDENCY_JSON_PATH)
target_graph = target_dependency.JavaTargetDependencyGraph(class_graph)
target_outbound, target_inbound, skipped_targets = gen_build_target_graph(
args.out_dir, args.include_testonly, args.no_cache)
stats_cmd = [
str(_SRC / 'tools/android/nullaway/java_file_stats.py'),
'--nomark-list-path',
str(_NOMARK_LIST_PATH)
]
if _FILE_CACHE_PATH.exists() and not args.no_cache:
stats_cmd += ['--cached-file-list', str(_FILE_CACHE_PATH)]
else:
stats_cmd += ['--output-file-list', str(_FILE_CACHE_PATH)]
subprocess.run(stats_cmd, capture_output=True)
nomark_paths = _NOMARK_LIST_PATH.read_text().splitlines()
unmarked_classes = set()
for path in nomark_paths:
idx = path.find('/org/')
if idx == -1:
idx = path.find('/com/')
if idx != -1:
full_name = path[idx + 1:].replace('/', '.').replace('.java', '')
else:
# Skip all files without /org/ or /com/ since they are likely not
# relevant for chrome packages (e.g. usually third_party stuff).
continue
if any(
re.match(pattern, full_name)
for pattern in _IGNORED_CLASSES_RE):
#print(f'Ignoring {full_name}')
continue
unmarked_classes.add(full_name)
class_nodes = [
n for n in class_graph.nodes
if n.package.startswith(_CHROME_PACKAGE_PREFIXES)
]
class_nodes.sort(key=lambda n: len(n.inbound), reverse=True)
filtered_class_nodes = [
n for n in class_nodes if n._unique_key in unmarked_classes
]
if args.verbose:
print(f'{len(class_nodes)=}')
print(f'{len(unmarked_classes)=}')
print(f'After filtering {len(filtered_class_nodes)}')
targets_num_unmarked = collections.defaultdict(int)
targets_unmarked_classes = collections.defaultdict(set)
java_targets = set()
substrings = ['test', 'junit']
for n in filtered_class_nodes:
for t in n.build_targets:
if '__' in t:
t = t.split('__')[0]
# Skip these jni ones, not interesting.
if 'jni_headers_java' in t or 'jni_java' in t:
continue
if t in skipped_targets:
continue
if not args.include_testonly and any(s in t for s in substrings):
continue
java_targets.add(t)
targets_num_unmarked[t] += 1
targets_unmarked_classes[t].add(n.name)
if args.verbose:
print(f'{len(targets_num_unmarked)=}')
target_transitive_outbound = collections.defaultdict(set)
target_transitive_inbound = collections.defaultdict(set)
for target in java_targets:
# These are deps of this target
target_transitive_outbound[target].update(target_outbound[target])
target_transitive_inbound[target].update(target_inbound[target])
node = target_graph.get_node_by_key(target)
if node is None:
continue
for n in node.outbound:
if n._unique_key not in target_transitive_outbound[target]:
#print(target, 'inbound', n._unique_key)
target_transitive_outbound[target].add(n._unique_key)
# These are targets that depend on this target
for n in node.inbound:
if n._unique_key not in target_transitive_inbound[target]:
#print(target, 'outbound', n._unique_key)
target_transitive_inbound[target].add(n._unique_key)
# Account for up to 5-levels of transitive deps (~2^5). This isn't perfect
# as some targets define deps in GN that the target graph doesn't pick up on
# (e.g. //chrome/browser/settings/internal_java), but this is good enough
# for this script.
for _ in range(5):
# Allow consideration of transitive deps that do not have any unmarked.
for target in java_targets:
# These are deps of this target
for t in target_outbound[target]:
target_transitive_outbound[target].update(
target_transitive_outbound[t])
# These are targets that depend on this target
for t in target_inbound[target]:
target_transitive_inbound[target].update(
target_transitive_inbound[t])
targets_unmarked_deps = collections.defaultdict(set)
targets_unmarked_dependents = collections.defaultdict(set)
for target in java_targets:
for dep in target_transitive_outbound[target]:
if targets_num_unmarked[dep] > 0:
targets_unmarked_deps[target].add(dep)
for dep in target_transitive_inbound[target]:
if targets_num_unmarked[dep] > 0:
targets_unmarked_dependents[target].add(dep)
if not args.all:
java_targets = set(t for t in java_targets
if len(targets_unmarked_deps[t]) == 0)
# Only keep targets that actually need to be annotated.
targets_list = sorted(
[t for t in java_targets if targets_num_unmarked[t] > 0])
for t in targets_list:
# Remove the // at the beginning for easy copy/pasting into siso.
status = f'{targets_num_unmarked[t]:2}'
if args.verbose >= 1:
status += f' {len(targets_unmarked_dependents[t]):2}'
# Without --all this list is all zeros.
if args.all:
status += f' {len(targets_unmarked_deps[t]):2}'
print(f'[{status}] {t[2:]}')
if args.verbose >= 2:
for c in targets_unmarked_classes[t]:
print(f'$ {c}')
if args.verbose >= 3:
for tt in targets_unmarked_deps[t]:
print(f'> {tt}')
if args.verbose >= 4:
for tt in targets_unmarked_dependents[t]:
print(f'< {tt}')
print('''\
For the status prefix:[A B C] <target-name>:
- A is the number of unmarked classes in that target.
- B (shows up only with -v) is the number of unmarked targets that depend on \
this target.
- C (shows up only with -v and -a) is the number of unmarked targets that this \
target depends on.''')
if __name__ == '__main__':
main()