1#!/usr/bin/env python3 2# Copyright (C) 2023 The Android Open Source Project 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); 5# you may not use this file except in compliance with the License. 6# You may obtain a copy of the License at 7# 8# http://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, 12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13# See the License for the specific language governing permissions and 14# limitations under the License. 15""" 16Enforce import rules for https://ui.perfetto.dev. 17""" 18 19import sys 20import os 21import re 22import collections 23import argparse 24import fnmatch 25 26ROOT_DIR = os.path.dirname( 27 os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 28UI_SRC_DIR = os.path.join(ROOT_DIR, 'ui', 'src') 29 30NODE_MODULES = '%node_modules%' # placeholder to depend on any node module. 31 32# The format of this array is: (src) -> (dst). 33# If src or dst are arrays, the semantic is the cartesian product, e.g.: 34# [a,b] -> [c,d] is equivalent to allowing a>c, a>d, b>c, b>d. 35DEPS_ALLOWLIST = [ 36 # Everything can depend on base/, protos and NPM packages. 37 ('*', ['/base/*', '/protos/index', '/gen/perfetto_version', NODE_MODULES]), 38 39 # Integration tests can depend on everything. 40 ('/test/*', '*'), 41 42 # Dependencies allowed for internal UI code. 43 ( 44 [ 45 '/frontend/*', 46 '/core/*', 47 '/common/*', 48 ], 49 [ 50 '/frontend/*', 51 '/core/*', 52 '/common/*', 53 '/components/*', 54 '/public/*', 55 '/trace_processor/*', 56 '/widgets/*', 57 '/protos/*', 58 '/gen/perfetto_version', 59 ], 60 ), 61 62 # /public (interfaces + lib) can depend only on a restricted surface. 63 ('/public/*', ['/base/*', '/trace_processor/*', '/widgets/*']), 64 65 # /plugins (and core_plugins) can depend only on a restricted surface. 66 ( 67 '/*plugins/*', 68 [ 69 '/base/*', '/public/*', '/trace_processor/*', '/widgets/*', 70 '/components/*' 71 ], 72 ), 73 74 # /components can depend on the 'base' packages & public 75 ( 76 '/components/*', 77 [ 78 '/base/*', 79 '/trace_processor/*', 80 '/widgets/*', 81 '/public/*', 82 '/components/*', 83 ], 84 ), 85 86 # Extra dependencies allowed for core_plugins only. 87 # TODO(priniano): remove this entry to figure out what it takes to move the 88 # remaining /core_plugins to /plugins and get rid of core_plugins. 89 ( 90 ['/core_plugins/*'], 91 ['/core/*', '/frontend/*', '/common/actions'], 92 ), 93 94 # Miscl legitimate deps. 95 ('/frontend/index', ['/gen/*']), 96 ('/traceconv/index', '/gen/traceconv'), 97 ('/engine/wasm_bridge', '/gen/trace_processor'), 98 ('/trace_processor/sql_utils/*', '/trace_processor/*'), 99 ('/protos/index', '/gen/protos'), 100 101 # ------ Technical debt that needs cleaning up below this point ------ 102 # TODO(stevegolton): Remove these once we extract core types out of 103 # components. 104 ( 105 '/components/*', 106 [ 107 '/core/trace_impl', 108 '/core/app_impl', 109 '/core/router', 110 '/core/flow_types', 111 '/core/fake_trace_impl', 112 '/core/raf_scheduler', 113 '/core/feature_flags', 114 '/frontend/css_constants', 115 ], 116 ), 117 118 # TODO(primiano): Record page-related technical debt. 119 ('/plugins/dev.perfetto.RecordTrace/*', 120 ['/frontend/globals', '/gen/protos']), 121 ('/chrome_extension/chrome_tracing_controller', 122 '/plugins/dev.perfetto.RecordTrace/*'), 123 124 # Bigtrace deps. 125 ('/bigtrace/*', ['/base/*', '/widgets/*', '/trace_processor/*']), 126 127 # TODO(primiano): misc tech debt. 128 ('/public/lib/extensions', '/frontend/*'), 129 ('/bigtrace/index', ['/core/live_reload', '/core/raf_scheduler']), 130 ('/plugins/dev.perfetto.HeapProfile/*', '/frontend/trace_converter'), 131] 132 133 134def all_source_files(): 135 for root, dirs, files in os.walk(UI_SRC_DIR, followlinks=False): 136 for name in files: 137 if name.endswith('.ts') and not name.endswith('.d.ts'): 138 yield os.path.join(root, name) 139 140 141def is_dir(path, cache={}): 142 try: 143 return cache[path] 144 except KeyError: 145 result = cache[path] = os.path.isdir(path) 146 return result 147 148 149def remove_prefix(s, prefix): 150 return s[len(prefix):] if s.startswith(prefix) else s 151 152 153def remove_suffix(s, suffix): 154 return s[:-len(suffix)] if s.endswith(suffix) else s 155 156 157def normalize_path(path): 158 return remove_suffix(remove_prefix(path, UI_SRC_DIR), '.ts') 159 160 161def find_plugin_declared_deps(path): 162 """Returns the set of deps declared by the plugin (if any) 163 164 It scans the plugin/index.ts file, and resolves the declared dependencies, 165 working out the path of the plugin we depend on (by looking at the imports). 166 Returns a tuple of the form (src_plugin_path, set{dst_plugin_path}) 167 Where: 168 src_plugin_path: is the normalized path of the input (e.g. /plugins/foo) 169 dst_path: is the normalized path of the declared dependency. 170 """ 171 src = normalize_path(path) 172 src_plugin = get_plugin_path(src) 173 if src_plugin is None or src != src_plugin + '/index': 174 # If the file is not a plugin, or is not the plugin index.ts, bail out. 175 return 176 # First extract all the default-imports in the file. Usually there is one for 177 # each imported plugin, of the form: 178 # import ThreadPlugin from '../plugins/dev.perfetto.Thread' 179 import_map = {} # 'ThreadPlugin' -> '/plugins/dev.perfetto.Thread' 180 for (src, target, default_import) in find_imports(path): 181 target_plugin = get_plugin_path(target) 182 if default_import is not None or target_plugin is not None: 183 import_map[default_import] = target_plugin 184 185 # Now extract the declared dependencies for the plugin. This looks for the 186 # statement 'static readonly dependencies = [ThreadPlugin]'. It can be broken 187 # down over multiple lines, so we approach this in two steps. First we find 188 # everything within the square brackets; then we remove spaces and \n and 189 # tokenize on commas 190 with open(path) as f: 191 s = f.read() 192 DEP_REGEX = r'^\s*static readonly dependencies\s*=\s*\[([^\]]*)\]' 193 all_deps = re.findall(DEP_REGEX, s, flags=re.MULTILINE) 194 if len(all_deps) == 0: 195 return 196 if len(all_deps) > 1: 197 raise Exception('Ambiguous plugin deps in %s: %s' % (path, all_deps)) 198 declared_deps = re.sub('\s*', '', all_deps[0]).split(',') 199 for imported_as in declared_deps: 200 resolved_dep = import_map.get(imported_as) 201 if resolved_dep is None: 202 raise Exception('Could not resolve import %s in %s' % (imported_as, src)) 203 yield (src_plugin, resolved_dep) 204 205 206def find_imports(path): 207 src = normalize_path(path) 208 directory, _ = os.path.split(src) 209 with open(path) as f: 210 s = f.read() 211 for m in re.finditer( 212 "^import\s+([^;]+)\s+from\s+'([^']+)';$", s, flags=re.MULTILINE): 213 # Flatten multi-line imports into one line, removing spaces. The resulting 214 # import line can look like: 215 # '{foo,bar,baz}' in most cases 216 # 'DefaultImportName' when doing import DefaultImportName from '...' 217 # 'DefaultImportName,{foo,bar,bar}' when doing a mixture of the above. 218 imports = re.sub('\s', '', m[1]) 219 default_import = (re.findall('^\w+', imports) + [None])[0] 220 221 # Normalize the imported file 222 target = m[2] 223 if target.startswith('.'): 224 target = os.path.normpath(os.path.join(directory, target)) 225 if is_dir(UI_SRC_DIR + target): 226 target = os.path.join(target, 'index') 227 228 yield (src, target, default_import) 229 230 231def path_to_id(path): 232 path = path.replace('/', '_') 233 path = path.replace('-', '_') 234 path = path.replace('@', '_at_') 235 path = path.replace('.', '_') 236 return path 237 238 239def is_external_dep(path): 240 return not path.startswith('/') 241 242 243def write_dot(graph, f): 244 print('digraph g {', file=f) 245 for node, edges in graph.items(): 246 node_id = path_to_id(node) 247 shape = 'rectangle' if is_external_dep(node) else 'ellipse' 248 print(f'{node_id} [shape={shape}, label="{node}"];', file=f) 249 250 for edge in edges: 251 edge_id = path_to_id(edge) 252 print(f'{node_id} -> {edge_id};', file=f) 253 print('}', file=f) 254 255 256def get_plugin_path(path): 257 m = re.match('^(/(?:core_)?plugins/([^/]+))/.*', path) 258 return m.group(1) if m is not None else None 259 260 261def flatten_rules(rules): 262 flat_deps = [] 263 for rule_src, rule_dst in rules: 264 src_list = rule_src if isinstance(rule_src, list) else [rule_src] 265 dst_list = rule_dst if isinstance(rule_dst, list) else [rule_dst] 266 for src in src_list: 267 for dst in dst_list: 268 flat_deps.append((src, dst)) 269 return flat_deps 270 271 272def get_node_modules(graph): 273 """Infers the dependencies onto NPM packages (node_modules) 274 275 An import is guessed to be a node module if doesn't contain any . or .. in the 276 path, and optionally starts with @. 277 """ 278 node_modules = set() 279 for _, imports in graph.items(): 280 for dst in imports: 281 if re.match(r'^[@a-z][a-z0-9-_/]+$', dst): 282 node_modules.add(dst) 283 return node_modules 284 285 286def check_one_import(src, dst, allowlist, plugin_declared_deps, node_modules): 287 # Translate node_module deps into the wildcard '%node_modules%' so it can be 288 # treated as a single entity. 289 if dst in node_modules: 290 dst = NODE_MODULES 291 292 # Always allow imports from the same directory or its own subdirectories. 293 src_dir = '/'.join(src.split('/')[:-1]) 294 dst_dir = '/'.join(dst.split('/')[:-1]) 295 if dst_dir.startswith(src_dir): 296 return True 297 298 # Match against the (flattened) allowlist. 299 for rule_src, rule_dst in allowlist: 300 if fnmatch.fnmatch(src, rule_src) and fnmatch.fnmatch(dst, rule_dst): 301 return True 302 303 # Check inter-plugin deps. 304 src_plugin = get_plugin_path(src) 305 dst_plugin = get_plugin_path(dst) 306 extra_err = '' 307 if src_plugin is not None and dst_plugin is not None: 308 if src_plugin == dst_plugin: 309 # Allow a plugin to depends on arbitrary subdirectories of itself. 310 return True 311 # Check if there is a dependency declared by plugins, via 312 # static readonly dependencies = [DstPlugin] 313 declared_deps = plugin_declared_deps.get(src_plugin, set()) 314 extra_err = '(plugin deps: %s)' % ','.join(declared_deps) 315 if dst_plugin in declared_deps: 316 return True 317 print('Import not allowed %s -> %s %s' % (src, dst, extra_err)) 318 return False 319 320 321def do_check(_options, graph): 322 result = 0 323 rules = flatten_rules(DEPS_ALLOWLIST) 324 node_modules = get_node_modules(graph) 325 326 # Build a map of depencies declared between plugin. The maps looks like: 327 # 'Foo' -> {'Bar', 'Baz'} # Foo declares a dependency on Bar and Baz 328 plugin_declared_deps = collections.defaultdict(set) 329 for path in all_source_files(): 330 for src_plugin, dst_plugin in find_plugin_declared_deps(path): 331 plugin_declared_deps[src_plugin].add(dst_plugin) 332 333 for src, imports in graph.items(): 334 for dst in imports: 335 if not check_one_import(src, dst, rules, plugin_declared_deps, 336 node_modules): 337 result = 1 338 return result 339 340 341def do_desc(options, graph): 342 print('Rules:') 343 for rule in flatten_rules(DEPS_ALLOWLIST): 344 print(' - %s' % rule) 345 346 347def do_print(options, graph): 348 for node, edges in graph.items(): 349 for edge in edges: 350 print("{}\t{}".format(node, edge)) 351 352 353def do_dot(options, graph): 354 355 def simplify(path): 356 if is_external_dep(path): 357 return path 358 return os.path.dirname(path) 359 360 new_graph = collections.defaultdict(set) 361 for node, edges in graph.items(): 362 for edge in edges: 363 new_graph[simplify(edge)] 364 new_graph[simplify(node)].add(simplify(edge)) 365 graph = new_graph 366 367 if options.ignore_external: 368 new_graph = collections.defaultdict(set) 369 for node, edges in graph.items(): 370 if is_external_dep(node): 371 continue 372 for edge in edges: 373 if is_external_dep(edge): 374 continue 375 new_graph[edge] 376 new_graph[node].add(edge) 377 graph = new_graph 378 379 write_dot(graph, sys.stdout) 380 return 0 381 382 383def main(): 384 parser = argparse.ArgumentParser(description=__doc__) 385 parser.set_defaults(func=do_check) 386 subparsers = parser.add_subparsers() 387 388 check_command = subparsers.add_parser( 389 'check', help='Check the rules (default)') 390 check_command.set_defaults(func=do_check) 391 392 desc_command = subparsers.add_parser('desc', help='Print the rules') 393 desc_command.set_defaults(func=do_desc) 394 395 print_command = subparsers.add_parser('print', help='Print all imports') 396 print_command.set_defaults(func=do_print) 397 398 dot_command = subparsers.add_parser( 399 'dot', 400 help='Output dependency graph in dot format suitble for use in ' + 401 'graphviz (e.g. ./tools/check_imports dot | dot -Tpng -ograph.png)') 402 dot_command.set_defaults(func=do_dot) 403 dot_command.add_argument( 404 '--ignore-external', 405 action='store_true', 406 help='Don\'t show external dependencies', 407 ) 408 409 # This is a general import graph of the form /plugins/foo/index -> /base/hash 410 graph = collections.defaultdict(set) 411 412 # Build the dep graph 413 for path in all_source_files(): 414 for src, target, _ in find_imports(path): 415 graph[src].add(target) 416 graph[target] 417 418 options = parser.parse_args() 419 return options.func(options, graph) 420 421 422if __name__ == '__main__': 423 sys.exit(main()) 424