xref: /aosp_15_r20/external/perfetto/python/tools/check_imports.py (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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