| #!/usr/bin/env python3 | 
 | # Copyright (C) 2023 The Android Open Source Project | 
 | # | 
 | # Licensed under the Apache License, Version 2.0 (the "License"); | 
 | # you may not use this file except in compliance with the License. | 
 | # You may obtain a copy of the License at | 
 | # | 
 | #      http://www.apache.org/licenses/LICENSE-2.0 | 
 | # | 
 | # Unless required by applicable law or agreed to in writing, software | 
 | # distributed under the License is distributed on an "AS IS" BASIS, | 
 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | 
 | # See the License for the specific language governing permissions and | 
 | # limitations under the License. | 
 | """ | 
 | Enforce import rules for https://ui.perfetto.dev. | 
 | Directory structure encodes ideas about the expected dependency graph | 
 | of the code in those directories. Both in a fuzzy sense: we expect code | 
 | withing a directory to have high cohesion within the directory and low | 
 | coupling (aka fewer imports) outside of the directory - but also | 
 | concrete rules: | 
 | - "base should not depend on the fronted" | 
 | - "plugins should only directly depend on the public API" | 
 | - "we should not have circular dependencies" | 
 |  | 
 | Without enforcement exceptions to this rule quickly slip in. This | 
 | script allows such rules to be enforced at presubmit time. | 
 | """ | 
 |  | 
 | import sys | 
 | import os | 
 | import re | 
 | import collections | 
 | import argparse | 
 |  | 
 | ROOT_DIR = os.path.dirname( | 
 |     os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | 
 | UI_SRC_DIR = os.path.join(ROOT_DIR, 'ui', 'src') | 
 |  | 
 | # Current plan for the dependency tree of the UI code (2023-09-21) | 
 | # black = current | 
 | # red = planning to remove | 
 | # green = planning to add | 
 | PLAN_DOT = """ | 
 | digraph g { | 
 |     mithril [shape=rectangle, label="mithril"]; | 
 |     protos [shape=rectangle, label="//protos/perfetto"]; | 
 |  | 
 |     _gen [shape=ellipse, label="/gen"]; | 
 |     _base [shape=ellipse, label="/base"]; | 
 |     _core [shape=ellipse, label="/core"]; | 
 |     _engine [shape=ellipse, label="/engine"]; | 
 |  | 
 |     _frontend [shape=ellipse, label="/frontend" color=red]; | 
 |     _common [shape=ellipse, label="/common" color=red]; | 
 |     _controller [shape=ellipse, label="/controller" color=red]; | 
 |     _tracks [shape=ellipse, label="/tracks" color=red]; | 
 |  | 
 |     _widgets [shape=ellipse, label="/widgets"]; | 
 |  | 
 |     _public [shape=ellipse, label="/public"]; | 
 |     _plugins [shape=ellipse, label="/plugins"]; | 
 |     _chrome_extension [shape=ellipse, label="/chrome_extension"]; | 
 |     _trace_processor [shape=ellipse, label="/trace_processor" color="green"]; | 
 |     _protos [shape=ellipse, label="/protos"]; | 
 |     engine_worker_bundle [shape=cds, label="Engine worker bundle"]; | 
 |     frontend_bundle [shape=cds, label="Frontend bundle"]; | 
 |  | 
 |     engine_worker_bundle -> _engine; | 
 |     frontend_bundle -> _core [color=green]; | 
 |     frontend_bundle -> _frontend [color=red]; | 
 |  | 
 |     _core -> _public; | 
 |     _plugins -> _public; | 
 |  | 
 |     _widgets -> _base; | 
 |     _core -> _base; | 
 |     _core -> _widgets; | 
 |  | 
 |  | 
 |     _widgets -> mithril; | 
 |     _plugins -> mithril; | 
 |     _core -> mithril | 
 |  | 
 |     _plugins -> _widgets; | 
 |  | 
 |     _core -> _chrome_extension; | 
 |  | 
 |     _frontend -> _widgets [color=red]; | 
 |     _common -> _core [color=red]; | 
 |     _frontend -> _core [color=red]; | 
 |     _controller -> _core [color=red]; | 
 |  | 
 |     _frontend -> _controller [color=red]; | 
 |     _frontend -> _common [color=red]; | 
 |     _controller -> _frontend  [color=red]; | 
 |     _controller -> _common [color=red]; | 
 |     _common -> _controller [color=red]; | 
 |     _common -> _frontend [color=red]; | 
 |     _tracks -> _frontend  [color=red]; | 
 |     _tracks -> _controller  [color=red]; | 
 |     _common -> _chrome_extension [color=red]; | 
 |  | 
 |     _core -> _trace_processor [color=green]; | 
 |  | 
 |     _engine -> _trace_processor [color=green]; | 
 |     _engine -> _common [color=red]; | 
 |     _engine -> _base; | 
 |  | 
 |     _gen -> protos; | 
 |     _core -> _gen [color=red]; | 
 |  | 
 |     _core -> _protos; | 
 |     _protos -> _gen; | 
 |     _trace_processor -> _protos [color=green]; | 
 |  | 
 |     _trace_processor -> _public [color=green]; | 
 |  | 
 |     npm_trace_processor [shape=cds, label="npm trace_processor" color="green"]; | 
 |     npm_trace_processor -> engine_worker_bundle [color="green"]; | 
 |     npm_trace_processor -> _trace_processor [color="green"]; | 
 | } | 
 | """ | 
 |  | 
 |  | 
 | class Failure(object): | 
 |  | 
 |   def __init__(self, path, rule): | 
 |     self.path = path | 
 |     self.rule = rule | 
 |  | 
 |   def __str__(self): | 
 |     nice_path = ["ui/src" + name + ".ts" for name in self.path] | 
 |     return ''.join([ | 
 |         'Forbidden dependency path:\n\n ', | 
 |         '\n    -> '.join(nice_path), | 
 |         '\n', | 
 |         '\n', | 
 |         str(self.rule), | 
 |         '\n', | 
 |     ]) | 
 |  | 
 |  | 
 | class AllowList(object): | 
 |  | 
 |   def __init__(self, allowed, dst, reasoning): | 
 |     self.allowed = allowed | 
 |     self.dst = dst | 
 |     self.reasoning = reasoning | 
 |  | 
 |   def check(self, graph): | 
 |     for node, edges in graph.items(): | 
 |       for edge in edges: | 
 |         if re.match(self.dst, edge): | 
 |           if not any(re.match(a, node) for a in self.allowed): | 
 |             yield Failure([node, edge], self) | 
 |  | 
 |   def __str__(self): | 
 |     return f'Only items in the allowlist ({self.allowed}) may directly depend on "{self.dst}" ' + self.reasoning | 
 |  | 
 |  | 
 | class NoDirectDep(object): | 
 |  | 
 |   def __init__(self, src, dst, reasoning): | 
 |     self.src = src | 
 |     self.dst = dst | 
 |     self.reasoning = reasoning | 
 |  | 
 |   def check(self, graph): | 
 |     for node, edges in graph.items(): | 
 |       if re.match(self.src, node): | 
 |         for edge in edges: | 
 |           if re.match(self.dst, edge): | 
 |             yield Failure([node, edge], self) | 
 |  | 
 |   def __str__(self): | 
 |     return f'"{self.src}" may not directly depend on "{self.dst}" ' + self.reasoning | 
 |  | 
 |  | 
 | class NoDep(object): | 
 |  | 
 |   def __init__(self, src, dst, reasoning): | 
 |     self.src = src | 
 |     self.dst = dst | 
 |     self.reasoning = reasoning | 
 |  | 
 |   def check(self, graph): | 
 |     for node in graph: | 
 |       if re.match(self.src, node): | 
 |         for connected, path in bfs(graph, node): | 
 |           if re.match(self.dst, connected): | 
 |             yield Failure(path, self) | 
 |  | 
 |   def __str__(self): | 
 |     return f'"{self.src}" may not depend on "{self.dst}" ' + self.reasoning | 
 |  | 
 |  | 
 | class NoCircularDeps(object): | 
 |  | 
 |   def __init__(self): | 
 |     pass | 
 |  | 
 |   def check(self, graph): | 
 |     for node in graph: | 
 |       for child in graph[node]: | 
 |         for reached, path in dfs(graph, child): | 
 |           if reached == node: | 
 |             yield Failure([node] + path, self) | 
 |  | 
 |   def __str__(self): | 
 |     return f'circular dependencies can cause complex issues' | 
 |  | 
 |  | 
 | # We have three kinds of rules: | 
 | # NoDirectDep(a, b) = files matching regex 'a' cannot *directly* import | 
 | #   files matching regex 'b' - but they may indirectly depend on them. | 
 | # NoDep(a, b) = as above but 'a' may not even transitively import 'b'. | 
 | # NoCircularDeps = forbid introduction of circular dependencies | 
 | RULES = [ | 
 |     AllowList( | 
 |         ['/protos/index'], | 
 |         r'/gen/protos', | 
 |         'protos should be re-exported from /protos/index without the nesting.', | 
 |     ), | 
 |     NoDirectDep( | 
 |         r'/plugins/.*', | 
 |         r'/core/.*', | 
 |         'instead plugins should depend on the API exposed at ui/src/public.', | 
 |     ), | 
 |     #NoDirectDep( | 
 |     #    r'/tracks/.*', | 
 |     #    r'/core/.*', | 
 |     #    'instead tracks should depend on the API exposed at ui/src/public.', | 
 |     #), | 
 |     NoDep( | 
 |         r'/core/.*', | 
 |         r'/plugins/.*', | 
 |         'otherwise the plugins are no longer optional.', | 
 |     ), | 
 |     NoDep( | 
 |         r'/core/.*', | 
 |         r'/frontend/.*', | 
 |         'trying to reduce the dependency mess as we refactor into core', | 
 |     ), | 
 |     NoDep( | 
 |         r'/core/.*', | 
 |         r'/common/.*', | 
 |         'trying to reduce the dependency mess as we refactor into core', | 
 |     ), | 
 |     NoDep( | 
 |         r'/core/.*', | 
 |         r'/controller/.*', | 
 |         'trying to reduce the dependency mess as we refactor into core', | 
 |     ), | 
 |     NoDep( | 
 |         r'/base/.*', | 
 |         r'/core/.*', | 
 |         'core should depend on base not the other way round', | 
 |     ), | 
 |     NoDep( | 
 |         r'/base/.*', | 
 |         r'/common/.*', | 
 |         'common should depend on base not the other way round', | 
 |     ), | 
 |     NoDep( | 
 |         r'/common/.*', | 
 |         r'/chrome_extension/.*', | 
 |         'chrome_extension must be a leaf', | 
 |     ), | 
 |  | 
 |     # Widgets | 
 |     NoDep( | 
 |         r'/widgets/.*', | 
 |         r'/frontend/.*', | 
 |         'widgets should only depend on base', | 
 |     ), | 
 |     NoDep( | 
 |         r'/widgets/.*', | 
 |         r'/core/.*', | 
 |         'widgets should only depend on base', | 
 |     ), | 
 |     NoDep( | 
 |         r'/widgets/.*', | 
 |         r'/plugins/.*', | 
 |         'widgets should only depend on base', | 
 |     ), | 
 |     NoDep( | 
 |         r'/widgets/.*', | 
 |         r'/common/.*', | 
 |         'widgets should only depend on base', | 
 |     ), | 
 |  | 
 |     # Bigtrace | 
 |     NoDep( | 
 |         r'/bigtrace/.*', | 
 |         r'/frontend/.*', | 
 |         'bigtrace should not depend on frontend', | 
 |     ), | 
 |     NoDep( | 
 |         r'/bigtrace/.*', | 
 |         r'/common/.*', | 
 |         'bigtrace should not depend on common', | 
 |     ), | 
 |     NoDep( | 
 |         r'/bigtrace/.*', | 
 |         r'/engine/.*', | 
 |         'bigtrace should not depend on engine', | 
 |     ), | 
 |     NoDep( | 
 |         r'/bigtrace/.*', | 
 |         r'/trace_processor/.*', | 
 |         'bigtrace should not depend on trace_processor', | 
 |     ), | 
 |     NoDep( | 
 |         r'/bigtrace/.*', | 
 |         r'/traceconv/.*', | 
 |         'bigtrace should not depend on traceconv', | 
 |     ), | 
 |     NoDep( | 
 |         r'/bigtrace/.*', | 
 |         r'/tracks/.*', | 
 |         'bigtrace should not depend on tracks', | 
 |     ), | 
 |     NoDep( | 
 |         r'/bigtrace/.*', | 
 |         r'/controller/.*', | 
 |         'bigtrace should not depend on controller', | 
 |     ), | 
 |  | 
 |     # Fails at the moment as we have several circular dependencies. One | 
 |     # example: | 
 |     # ui/src/frontend/cookie_consent.ts | 
 |     #    -> ui/src/frontend/globals.ts | 
 |     #    -> ui/src/frontend/router.ts | 
 |     #    -> ui/src/frontend/pages.ts | 
 |     #    -> ui/src/frontend/cookie_consent.ts | 
 |     #NoCircularDeps(), | 
 | ] | 
 |  | 
 |  | 
 | def all_source_files(): | 
 |   for root, dirs, files in os.walk(UI_SRC_DIR, followlinks=False): | 
 |     for name in files: | 
 |       if name.endswith('.ts') and not name.endswith('.d.ts'): | 
 |         yield os.path.join(root, name) | 
 |  | 
 |  | 
 | def is_dir(path, cache={}): | 
 |   try: | 
 |     return cache[path] | 
 |   except KeyError: | 
 |     result = cache[path] = os.path.isdir(path) | 
 |     return result | 
 |  | 
 |  | 
 | def remove_prefix(s, prefix): | 
 |   return s[len(prefix):] if s.startswith(prefix) else s | 
 |  | 
 |  | 
 | def remove_suffix(s, suffix): | 
 |   return s[:-len(suffix)] if s.endswith(suffix) else s | 
 |  | 
 |  | 
 | def find_imports(path): | 
 |   src = path | 
 |   src = remove_prefix(src, UI_SRC_DIR) | 
 |   src = remove_suffix(src, '.ts') | 
 |   directory, _ = os.path.split(src) | 
 |   with open(path) as f: | 
 |     s = f.read() | 
 |     for m in re.finditer("^import[^']*'([^']*)';", s, flags=re.MULTILINE): | 
 |       raw_target = m[1] | 
 |       if raw_target.startswith('.'): | 
 |         target = os.path.normpath(os.path.join(directory, raw_target)) | 
 |         if is_dir(UI_SRC_DIR + target): | 
 |           target = os.path.join(target, 'index') | 
 |       else: | 
 |         target = raw_target | 
 |       yield (src, target) | 
 |  | 
 |  | 
 | def path_to_id(path): | 
 |   path = path.replace('/', '_') | 
 |   path = path.replace('-', '_') | 
 |   path = path.replace('@', '_at_') | 
 |   path = path.replace('.', '_') | 
 |   return path | 
 |  | 
 |  | 
 | def is_external_dep(path): | 
 |   return not path.startswith('/') | 
 |  | 
 |  | 
 | def bfs(graph, src): | 
 |   seen = set() | 
 |   queue = [(src, [])] | 
 |  | 
 |   while queue: | 
 |     node, path = queue.pop(0) | 
 |     if node in seen: | 
 |       continue | 
 |  | 
 |     seen.add(node) | 
 |  | 
 |     path = path[:] | 
 |     path.append(node) | 
 |  | 
 |     yield node, path | 
 |     queue.extend([(child, path) for child in graph[node]]) | 
 |  | 
 |  | 
 | def dfs(graph, src): | 
 |   seen = set() | 
 |   queue = [(src, [])] | 
 |  | 
 |   while queue: | 
 |     node, path = queue.pop() | 
 |     if node in seen: | 
 |       continue | 
 |  | 
 |     seen.add(node) | 
 |  | 
 |     path = path[:] | 
 |     path.append(node) | 
 |  | 
 |     yield node, path | 
 |     queue.extend([(child, path) for child in graph[node]]) | 
 |  | 
 |  | 
 | def write_dot(graph, f): | 
 |   print('digraph g {', file=f) | 
 |   for node, edges in graph.items(): | 
 |     node_id = path_to_id(node) | 
 |     shape = 'rectangle' if is_external_dep(node) else 'ellipse' | 
 |     print(f'{node_id} [shape={shape}, label="{node}"];', file=f) | 
 |  | 
 |     for edge in edges: | 
 |       edge_id = path_to_id(edge) | 
 |       print(f'{node_id} -> {edge_id};', file=f) | 
 |   print('}', file=f) | 
 |  | 
 |  | 
 | def do_check(options, graph): | 
 |   for rule in RULES: | 
 |     for failure in rule.check(graph): | 
 |       print(failure) | 
 |       return 1 | 
 |   return 0 | 
 |  | 
 |  | 
 | def do_desc(options, graph): | 
 |   print('Rules:') | 
 |   for rule in RULES: | 
 |     print("  - ", end='') | 
 |     print(rule) | 
 |  | 
 |  | 
 | def do_print(options, graph): | 
 |   for node, edges in graph.items(): | 
 |     for edge in edges: | 
 |       print("{}\t{}".format(node, edge)) | 
 |  | 
 |  | 
 | def do_dot(options, graph): | 
 |  | 
 |   def simplify(path): | 
 |     if is_external_dep(path): | 
 |       return path | 
 |     return os.path.dirname(path) | 
 |  | 
 |   if options.simplify: | 
 |     new_graph = collections.defaultdict(set) | 
 |     for node, edges in graph.items(): | 
 |       for edge in edges: | 
 |         new_graph[simplify(edge)] | 
 |         new_graph[simplify(node)].add(simplify(edge)) | 
 |     graph = new_graph | 
 |  | 
 |   if options.ignore_external: | 
 |     new_graph = collections.defaultdict(set) | 
 |     for node, edges in graph.items(): | 
 |       if is_external_dep(node): | 
 |         continue | 
 |       for edge in edges: | 
 |         if is_external_dep(edge): | 
 |           continue | 
 |         new_graph[edge] | 
 |         new_graph[node].add(edge) | 
 |     graph = new_graph | 
 |  | 
 |   write_dot(graph, sys.stdout) | 
 |   return 0 | 
 |  | 
 |  | 
 | def do_plan_dot(options, _): | 
 |   print(PLAN_DOT, file=sys.stdout) | 
 |   return 0 | 
 |  | 
 |  | 
 | def main(): | 
 |   parser = argparse.ArgumentParser(description=__doc__) | 
 |   parser.set_defaults(func=do_check) | 
 |   subparsers = parser.add_subparsers() | 
 |  | 
 |   check_command = subparsers.add_parser( | 
 |       'check', help='Check the rules (default)') | 
 |   check_command.set_defaults(func=do_check) | 
 |  | 
 |   desc_command = subparsers.add_parser('desc', help='Print the rules') | 
 |   desc_command.set_defaults(func=do_desc) | 
 |  | 
 |   print_command = subparsers.add_parser('print', help='Print all imports') | 
 |   print_command.set_defaults(func=do_print) | 
 |  | 
 |   dot_command = subparsers.add_parser( | 
 |       'dot', | 
 |       help='Output dependency graph in dot format suitble for use in graphviz (e.g. ./tools/check_imports dot | dot -Tpng -ograph.png)' | 
 |   ) | 
 |   dot_command.set_defaults(func=do_dot) | 
 |   dot_command.add_argument( | 
 |       '--simplify', | 
 |       action='store_true', | 
 |       help='Show directories rather than files', | 
 |   ) | 
 |   dot_command.add_argument( | 
 |       '--ignore-external', | 
 |       action='store_true', | 
 |       help='Don\'t show external dependencies', | 
 |   ) | 
 |  | 
 |   plan_dot_command = subparsers.add_parser( | 
 |       'plan-dot', | 
 |       help='Output planned dependency graph in dot format suitble for use in graphviz (e.g. ./tools/check_imports plan-dot | dot -Tpng -ograph.png)' | 
 |   ) | 
 |   plan_dot_command.set_defaults(func=do_plan_dot) | 
 |  | 
 |   graph = collections.defaultdict(set) | 
 |   for path in all_source_files(): | 
 |     for src, target in find_imports(path): | 
 |       graph[src].add(target) | 
 |       graph[target] | 
 |  | 
 |   options = parser.parse_args() | 
 |   return options.func(options, graph) | 
 |  | 
 |  | 
 | if __name__ == '__main__': | 
 |   sys.exit(main()) |