• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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