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. 17Directory structure encodes ideas about the expected dependency graph 18of the code in those directories. Both in a fuzzy sense: we expect code 19withing a directory to have high cohesion within the directory and low 20coupling (aka fewer imports) outside of the directory - but also 21concrete rules: 22- "base should not depend on the fronted" 23- "plugins should only directly depend on the public API" 24- "we should not have circular dependencies" 25 26Without enforcement exceptions to this rule quickly slip in. This 27script allows such rules to be enforced at presubmit time. 28""" 29 30import sys 31import os 32import re 33import collections 34import argparse 35 36ROOT_DIR = os.path.dirname( 37 os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 38UI_SRC_DIR = os.path.join(ROOT_DIR, 'ui', 'src') 39 40# Current plan for the dependency tree of the UI code (2023-09-21) 41# black = current 42# red = planning to remove 43# green = planning to add 44PLAN_DOT = """ 45digraph g { 46 mithril [shape=rectangle, label="mithril"]; 47 protos [shape=rectangle, label="//protos/perfetto"]; 48 49 _gen [shape=ellipse, label="/gen"]; 50 _base [shape=ellipse, label="/base"]; 51 _core [shape=ellipse, label="/core"]; 52 _engine [shape=ellipse, label="/engine"]; 53 54 _frontend [shape=ellipse, label="/frontend" color=red]; 55 _common [shape=ellipse, label="/common" color=red]; 56 _controller [shape=ellipse, label="/controller" color=red]; 57 _tracks [shape=ellipse, label="/tracks" color=red]; 58 59 _widgets [shape=ellipse, label="/widgets"]; 60 61 _public [shape=ellipse, label="/public"]; 62 _plugins [shape=ellipse, label="/plugins"]; 63 _chrome_extension [shape=ellipse, label="/chrome_extension"]; 64 _trace_processor [shape=ellipse, label="/trace_processor" color="green"]; 65 _protos [shape=ellipse, label="/protos"]; 66 engine_worker_bundle [shape=cds, label="Engine worker bundle"]; 67 frontend_bundle [shape=cds, label="Frontend bundle"]; 68 69 engine_worker_bundle -> _engine; 70 frontend_bundle -> _core [color=green]; 71 frontend_bundle -> _frontend [color=red]; 72 73 _core -> _public; 74 _plugins -> _public; 75 76 _widgets -> _base; 77 _core -> _base; 78 _core -> _widgets; 79 80 81 _widgets -> mithril; 82 _plugins -> mithril; 83 _core -> mithril 84 85 _plugins -> _widgets; 86 87 _core -> _chrome_extension; 88 89 _frontend -> _widgets [color=red]; 90 _common -> _core [color=red]; 91 _frontend -> _core [color=red]; 92 _controller -> _core [color=red]; 93 94 _frontend -> _controller [color=red]; 95 _frontend -> _common [color=red]; 96 _controller -> _frontend [color=red]; 97 _controller -> _common [color=red]; 98 _common -> _controller [color=red]; 99 _common -> _frontend [color=red]; 100 _tracks -> _frontend [color=red]; 101 _tracks -> _controller [color=red]; 102 _common -> _chrome_extension [color=red]; 103 104 _core -> _trace_processor [color=green]; 105 106 _engine -> _trace_processor [color=green]; 107 _engine -> _common [color=red]; 108 _engine -> _base; 109 110 _gen -> protos; 111 _core -> _gen [color=red]; 112 113 _core -> _protos; 114 _protos -> _gen; 115 _trace_processor -> _protos [color=green]; 116 117 _trace_processor -> _public [color=green]; 118 119 npm_trace_processor [shape=cds, label="npm trace_processor" color="green"]; 120 npm_trace_processor -> engine_worker_bundle [color="green"]; 121 npm_trace_processor -> _trace_processor [color="green"]; 122} 123""" 124 125 126class Failure(object): 127 128 def __init__(self, path, rule): 129 self.path = path 130 self.rule = rule 131 132 def __str__(self): 133 nice_path = ["ui/src" + name + ".ts" for name in self.path] 134 return ''.join([ 135 'Forbidden dependency path:\n\n ', 136 '\n -> '.join(nice_path), 137 '\n', 138 '\n', 139 str(self.rule), 140 '\n', 141 ]) 142 143 144class AllowList(object): 145 146 def __init__(self, allowed, dst, reasoning): 147 self.allowed = allowed 148 self.dst = dst 149 self.reasoning = reasoning 150 151 def check(self, graph): 152 for node, edges in graph.items(): 153 for edge in edges: 154 if re.match(self.dst, edge): 155 if not any(re.match(a, node) for a in self.allowed): 156 yield Failure([node, edge], self) 157 158 def __str__(self): 159 return f'Only items in the allowlist ({self.allowed}) may directly depend on "{self.dst}" ' + self.reasoning 160 161 162class NoDirectDep(object): 163 164 def __init__(self, src, dst, reasoning): 165 self.src = src 166 self.dst = dst 167 self.reasoning = reasoning 168 169 def check(self, graph): 170 for node, edges in graph.items(): 171 if re.match(self.src, node): 172 for edge in edges: 173 if re.match(self.dst, edge): 174 yield Failure([node, edge], self) 175 176 def __str__(self): 177 return f'"{self.src}" may not directly depend on "{self.dst}" ' + self.reasoning 178 179 180class NoDep(object): 181 182 def __init__(self, src, dst, reasoning): 183 self.src = src 184 self.dst = dst 185 self.reasoning = reasoning 186 187 def check(self, graph): 188 for node in graph: 189 if re.match(self.src, node): 190 for connected, path in bfs(graph, node): 191 if re.match(self.dst, connected): 192 yield Failure(path, self) 193 194 def __str__(self): 195 return f'"{self.src}" may not depend on "{self.dst}" ' + self.reasoning 196 197 198class NoCircularDeps(object): 199 200 def __init__(self): 201 pass 202 203 def check(self, graph): 204 for node in graph: 205 for child in graph[node]: 206 for reached, path in dfs(graph, child): 207 if reached == node: 208 yield Failure([node] + path, self) 209 210 def __str__(self): 211 return f'circular dependencies can cause complex issues' 212 213 214# We have three kinds of rules: 215# NoDirectDep(a, b) = files matching regex 'a' cannot *directly* import 216# files matching regex 'b' - but they may indirectly depend on them. 217# NoDep(a, b) = as above but 'a' may not even transitively import 'b'. 218# NoCircularDeps = forbid introduction of circular dependencies 219RULES = [ 220 AllowList( 221 ['/protos/index'], 222 r'/gen/protos', 223 'protos should be re-exported from /protos/index without the nesting.', 224 ), 225 NoDirectDep( 226 r'/plugins/.*', 227 r'/core/.*', 228 'instead plugins should depend on the API exposed at ui/src/public.', 229 ), 230 NoDirectDep( 231 r"/frontend/.*", 232 r"/core_plugins/.*", 233 "core code should not depend on plugins.", 234 ), 235 NoDirectDep( 236 r"/core/.*", 237 r"/core_plugins/.*", 238 "core code should not depend on plugins.", 239 ), 240 NoDirectDep( 241 r"/base/.*", 242 r"/core_plugins/.*", 243 "core code should not depend on plugins.", 244 ), 245 #NoDirectDep( 246 # r'/tracks/.*', 247 # r'/core/.*', 248 # 'instead tracks should depend on the API exposed at ui/src/public.', 249 #), 250 NoDep( 251 r'/core/.*', 252 r'/plugins/.*', 253 'otherwise the plugins are no longer optional.', 254 ), 255 NoDep( 256 r'/core/.*', 257 r'/frontend/.*', 258 'trying to reduce the dependency mess as we refactor into core', 259 ), 260 NoDep( 261 r'/core/.*', 262 r'/common/.*', 263 'trying to reduce the dependency mess as we refactor into core', 264 ), 265 NoDep( 266 r'/core/.*', 267 r'/controller/.*', 268 'trying to reduce the dependency mess as we refactor into core', 269 ), 270 NoDep( 271 r'/base/.*', 272 r'/core/.*', 273 'core should depend on base not the other way round', 274 ), 275 NoDep( 276 r'/base/.*', 277 r'/common/.*', 278 'common should depend on base not the other way round', 279 ), 280 NoDep( 281 r'/common/.*', 282 r'/chrome_extension/.*', 283 'chrome_extension must be a leaf', 284 ), 285 286 # Widgets 287 NoDep( 288 r'/widgets/.*', 289 r'/frontend/.*', 290 'widgets should only depend on base', 291 ), 292 NoDep( 293 r'/widgets/.*', 294 r'/core/.*', 295 'widgets should only depend on base', 296 ), 297 NoDep( 298 r'/widgets/.*', 299 r'/plugins/.*', 300 'widgets should only depend on base', 301 ), 302 NoDep( 303 r'/widgets/.*', 304 r'/common/.*', 305 'widgets should only depend on base', 306 ), 307 308 # Bigtrace 309 NoDep( 310 r'/bigtrace/.*', 311 r'/frontend/.*', 312 'bigtrace should not depend on frontend', 313 ), 314 NoDep( 315 r'/bigtrace/.*', 316 r'/common/.*', 317 'bigtrace should not depend on common', 318 ), 319 NoDep( 320 r'/bigtrace/.*', 321 r'/engine/.*', 322 'bigtrace should not depend on engine', 323 ), 324 NoDep( 325 r'/bigtrace/.*', 326 r'/trace_processor/.*', 327 'bigtrace should not depend on trace_processor', 328 ), 329 NoDep( 330 r'/bigtrace/.*', 331 r'/traceconv/.*', 332 'bigtrace should not depend on traceconv', 333 ), 334 NoDep( 335 r'/bigtrace/.*', 336 r'/tracks/.*', 337 'bigtrace should not depend on tracks', 338 ), 339 NoDep( 340 r'/bigtrace/.*', 341 r'/controller/.*', 342 'bigtrace should not depend on controller', 343 ), 344 345 # Fails at the moment as we have several circular dependencies. One 346 # example: 347 # ui/src/frontend/cookie_consent.ts 348 # -> ui/src/frontend/globals.ts 349 # -> ui/src/frontend/router.ts 350 # -> ui/src/frontend/pages.ts 351 # -> ui/src/frontend/cookie_consent.ts 352 #NoCircularDeps(), 353] 354 355 356def all_source_files(): 357 for root, dirs, files in os.walk(UI_SRC_DIR, followlinks=False): 358 for name in files: 359 if name.endswith('.ts') and not name.endswith('.d.ts'): 360 yield os.path.join(root, name) 361 362 363def is_dir(path, cache={}): 364 try: 365 return cache[path] 366 except KeyError: 367 result = cache[path] = os.path.isdir(path) 368 return result 369 370 371def remove_prefix(s, prefix): 372 return s[len(prefix):] if s.startswith(prefix) else s 373 374 375def remove_suffix(s, suffix): 376 return s[:-len(suffix)] if s.endswith(suffix) else s 377 378 379def find_imports(path): 380 src = path 381 src = remove_prefix(src, UI_SRC_DIR) 382 src = remove_suffix(src, '.ts') 383 directory, _ = os.path.split(src) 384 with open(path) as f: 385 s = f.read() 386 for m in re.finditer("^import[^']*'([^']*)';", s, flags=re.MULTILINE): 387 raw_target = m[1] 388 if raw_target.startswith('.'): 389 target = os.path.normpath(os.path.join(directory, raw_target)) 390 if is_dir(UI_SRC_DIR + target): 391 target = os.path.join(target, 'index') 392 else: 393 target = raw_target 394 yield (src, target) 395 396 397def path_to_id(path): 398 path = path.replace('/', '_') 399 path = path.replace('-', '_') 400 path = path.replace('@', '_at_') 401 path = path.replace('.', '_') 402 return path 403 404 405def is_external_dep(path): 406 return not path.startswith('/') 407 408 409def bfs(graph, src): 410 seen = set() 411 queue = [(src, [])] 412 413 while queue: 414 node, path = queue.pop(0) 415 if node in seen: 416 continue 417 418 seen.add(node) 419 420 path = path[:] 421 path.append(node) 422 423 yield node, path 424 queue.extend([(child, path) for child in graph[node]]) 425 426 427def dfs(graph, src): 428 seen = set() 429 queue = [(src, [])] 430 431 while queue: 432 node, path = queue.pop() 433 if node in seen: 434 continue 435 436 seen.add(node) 437 438 path = path[:] 439 path.append(node) 440 441 yield node, path 442 queue.extend([(child, path) for child in graph[node]]) 443 444 445def write_dot(graph, f): 446 print('digraph g {', file=f) 447 for node, edges in graph.items(): 448 node_id = path_to_id(node) 449 shape = 'rectangle' if is_external_dep(node) else 'ellipse' 450 print(f'{node_id} [shape={shape}, label="{node}"];', file=f) 451 452 for edge in edges: 453 edge_id = path_to_id(edge) 454 print(f'{node_id} -> {edge_id};', file=f) 455 print('}', file=f) 456 457 458def do_check(options, graph): 459 for rule in RULES: 460 for failure in rule.check(graph): 461 print(failure) 462 return 1 463 return 0 464 465 466def do_desc(options, graph): 467 print('Rules:') 468 for rule in RULES: 469 print(" - ", end='') 470 print(rule) 471 472 473def do_print(options, graph): 474 for node, edges in graph.items(): 475 for edge in edges: 476 print("{}\t{}".format(node, edge)) 477 478 479def do_dot(options, graph): 480 481 def simplify(path): 482 if is_external_dep(path): 483 return path 484 return os.path.dirname(path) 485 486 if options.simplify: 487 new_graph = collections.defaultdict(set) 488 for node, edges in graph.items(): 489 for edge in edges: 490 new_graph[simplify(edge)] 491 new_graph[simplify(node)].add(simplify(edge)) 492 graph = new_graph 493 494 if options.ignore_external: 495 new_graph = collections.defaultdict(set) 496 for node, edges in graph.items(): 497 if is_external_dep(node): 498 continue 499 for edge in edges: 500 if is_external_dep(edge): 501 continue 502 new_graph[edge] 503 new_graph[node].add(edge) 504 graph = new_graph 505 506 write_dot(graph, sys.stdout) 507 return 0 508 509 510def do_plan_dot(options, _): 511 print(PLAN_DOT, file=sys.stdout) 512 return 0 513 514 515def main(): 516 parser = argparse.ArgumentParser(description=__doc__) 517 parser.set_defaults(func=do_check) 518 subparsers = parser.add_subparsers() 519 520 check_command = subparsers.add_parser( 521 'check', help='Check the rules (default)') 522 check_command.set_defaults(func=do_check) 523 524 desc_command = subparsers.add_parser('desc', help='Print the rules') 525 desc_command.set_defaults(func=do_desc) 526 527 print_command = subparsers.add_parser('print', help='Print all imports') 528 print_command.set_defaults(func=do_print) 529 530 dot_command = subparsers.add_parser( 531 'dot', 532 help='Output dependency graph in dot format suitble for use in graphviz (e.g. ./tools/check_imports dot | dot -Tpng -ograph.png)' 533 ) 534 dot_command.set_defaults(func=do_dot) 535 dot_command.add_argument( 536 '--simplify', 537 action='store_true', 538 help='Show directories rather than files', 539 ) 540 dot_command.add_argument( 541 '--ignore-external', 542 action='store_true', 543 help='Don\'t show external dependencies', 544 ) 545 546 plan_dot_command = subparsers.add_parser( 547 'plan-dot', 548 help='Output planned dependency graph in dot format suitble for use in graphviz (e.g. ./tools/check_imports plan-dot | dot -Tpng -ograph.png)' 549 ) 550 plan_dot_command.set_defaults(func=do_plan_dot) 551 552 graph = collections.defaultdict(set) 553 for path in all_source_files(): 554 for src, target in find_imports(path): 555 graph[src].add(target) 556 graph[target] 557 558 options = parser.parse_args() 559 return options.func(options, graph) 560 561 562if __name__ == '__main__': 563 sys.exit(main()) 564