zulip/tools/js-dep-visualizer.py

298 lines
9.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""
$ ./tools/js-dep-visualizer.py
$ dot -Tpng var/zulip-deps.dot -o var/zulip-deps.png
"""
import os
import re
import subprocess
import sys
from collections import defaultdict
from typing import Any, DefaultDict, Dict, List, Set, Tuple
Edge = Tuple[str, str]
EdgeSet = Set[Edge]
Method = str
MethodDict = DefaultDict[Edge, List[Method]]
TOOLS_DIR = os.path.abspath(os.path.dirname(__file__))
ROOT_DIR = os.path.dirname(TOOLS_DIR)
sys.path.insert(0, ROOT_DIR)
from tools.lib.graph import (
Graph,
make_dot_file,
best_edge_to_remove,
)
JS_FILES_DIR = os.path.join(ROOT_DIR, 'static/js')
OUTPUT_FILE_PATH = os.path.relpath(os.path.join(ROOT_DIR, 'var/zulip-deps.dot'))
PNG_FILE_PATH = os.path.relpath(os.path.join(ROOT_DIR, 'var/zulip-deps.png'))
def get_js_edges() -> Tuple[EdgeSet, MethodDict]:
names = set()
modules = [] # type: List[Dict[str, Any]]
for js_file in os.listdir(JS_FILES_DIR):
if not js_file.endswith('.js') and not js_file.endswith('.ts'):
continue
name = js_file[:-3] # Remove .js or .ts
path = os.path.join(JS_FILES_DIR, js_file)
names.add(name)
modules.append(dict(
name=name,
path=path,
regex=re.compile(r'[^_]{}\.\w+\('.format(name))
))
comment_regex = re.compile(r'\s+//')
call_regex = re.compile(r'[^_](\w+\.\w+)\(')
methods = defaultdict(list) # type: DefaultDict[Edge, List[Method]]
edges = set()
for module in modules:
parent = module['name']
with open(module['path']) as f:
for line in f:
if comment_regex.match(line):
continue
if 'subs.forEach' in line:
continue
m = call_regex.search(line)
if not m:
continue
for g in m.groups():
child, method = g.split('.')
if (child not in names):
continue
if child == parent:
continue
tup = (parent, child)
edges.add(tup)
methods[tup].append(method)
return edges, methods
def find_edges_to_remove(graph: Graph, methods: MethodDict) -> Tuple[Graph, List[Edge]]:
EXEMPT_EDGES = [
# These are sensible dependencies, so don't cut them.
('rows', 'message_store'),
('filter', 'stream_data'),
('server_events', 'user_events'),
('compose_fade', 'stream_data'),
('narrow', 'message_list'),
('stream_list', 'topic_list',),
('subs', 'stream_muting'),
('hashchange', 'settings'),
('tutorial', 'narrow'),
('activity', 'resize'),
('hashchange', 'drafts'),
('compose', 'echo'),
('compose', 'resize'),
('settings', 'resize'),
('compose', 'unread_ops'),
('compose', 'drafts'),
('echo', 'message_edit'),
('echo', 'stream_list'),
('hashchange', 'narrow'),
('hashchange', 'subs'),
('message_edit', 'echo'),
('popovers', 'message_edit'),
('unread_ui', 'activity'),
('message_fetch', 'message_util'),
('message_fetch', 'resize'),
('message_util', 'resize'),
('notifications', 'tutorial'),
('message_util', 'unread_ui'),
('muting_ui', 'stream_list'),
('muting_ui', 'unread_ui'),
('stream_popover', 'subs'),
('stream_popover', 'muting_ui'),
('narrow', 'message_fetch'),
('narrow', 'message_util'),
('narrow', 'navigate'),
('unread_ops', 'unread_ui'),
('narrow', 'unread_ops'),
('navigate', 'unread_ops'),
('pm_list', 'unread_ui'),
('stream_list', 'unread_ui'),
('popovers', 'compose'),
('popovers', 'muting_ui'),
('popovers', 'narrow'),
('popovers', 'resize'),
('pm_list', 'resize'),
('notifications', 'navigate'),
('compose', 'socket'),
('stream_muting', 'message_util'),
('subs', 'stream_list'),
('ui', 'message_fetch'),
('ui', 'unread_ops'),
('condense', 'message_viewport'),
('compose_actions', 'compose'),
('compose_actions', 'resize'),
('settings_streams', 'stream_data'),
('drafts', 'hashchange'),
('settings_notifications', 'stream_edit'),
('compose', 'stream_edit'),
('subs', 'stream_edit'),
('narrow_state', 'stream_data'),
('stream_edit', 'stream_list'),
('reactions', 'emoji_picker'),
('message_edit', 'resize'),
] # type: List[Edge]
def is_exempt(edge: Tuple[str, str]) -> bool:
parent, child = edge
if edge == ('server_events', 'reload'):
return False
if parent in ['server_events', 'user_events', 'stream_events',
'message_events', 'reload']:
return True
if child == 'rows':
return True
return edge in EXEMPT_EDGES
APPROVED_CUTS = [
('stream_edit', 'stream_events'),
('unread_ui', 'pointer'),
('typing_events', 'narrow'),
('echo', 'message_events'),
('resize', 'navigate'),
('narrow', 'search'),
('subs', 'stream_events'),
('stream_color', 'tab_bar'),
('stream_color', 'subs'),
('stream_data', 'narrow'),
('unread', 'narrow'),
('composebox_typeahead', 'compose'),
('message_list', 'message_edit'),
('message_edit', 'compose'),
('message_store', 'compose'),
('settings_notifications', 'subs'),
('settings', 'settings_muting'),
('message_fetch', 'tutorial'),
('settings', 'subs'),
('activity', 'narrow'),
('compose', 'compose_actions'),
('compose', 'subs'),
('compose_actions', 'drafts'),
('compose_actions', 'narrow'),
('compose_actions', 'unread_ops'),
('drafts', 'compose'),
('drafts', 'echo'),
('echo', 'compose'),
('echo', 'narrow'),
('echo', 'pm_list'),
('echo', 'ui'),
('message_fetch', 'activity'),
('message_fetch', 'narrow'),
('message_fetch', 'pm_list'),
('message_fetch', 'stream_list'),
('message_fetch', 'ui'),
('narrow', 'ui'),
('message_util', 'compose'),
('subs', 'compose'),
('narrow', 'hashchange'),
('subs', 'hashchange'),
('navigate', 'narrow'),
('navigate', 'stream_list'),
('pm_list', 'narrow'),
('pm_list', 'stream_popover'),
('muting_ui', 'stream_popover'),
('popovers', 'stream_popover'),
('topic_list', 'stream_popover'),
('stream_edit', 'subs'),
('topic_list', 'narrow'),
('stream_list', 'narrow'),
('stream_list', 'pm_list'),
('stream_list', 'unread_ops'),
('notifications', 'ui'),
('notifications', 'narrow'),
('notifications', 'unread_ops'),
('typing', 'narrow'),
('message_events', 'compose'),
('stream_muting', 'stream_list'),
('subs', 'narrow'),
('unread_ui', 'pm_list'),
('unread_ui', 'stream_list'),
('overlays', 'hashchange'),
('emoji_picker', 'reactions'),
]
def cut_is_legal(edge: Edge) -> bool:
parent, child = edge
if child in ['reload', 'popovers', 'overlays', 'notifications',
'server_events', 'compose_actions']:
return True
return edge in APPROVED_CUTS
graph.remove_exterior_nodes()
removed_edges = list()
print()
while graph.num_edges() > 0:
edge = best_edge_to_remove(graph, is_exempt)
if edge is None:
print('we may not be allowing edge cuts!!!')
break
if cut_is_legal(edge):
graph = graph.minus_edge(edge)
graph.remove_exterior_nodes()
removed_edges.append(edge)
else:
for removed_edge in removed_edges:
print(removed_edge)
print()
edge_str = str(edge) + ','
print(edge_str)
for method in methods[edge]:
print(' ' + method)
break
return graph, removed_edges
def report_roadmap(edges: List[Edge], methods: MethodDict) -> None:
child_modules = {child for parent, child in edges}
module_methods = defaultdict(set) # type: DefaultDict[str, Set[str]]
callers = defaultdict(set) # type: DefaultDict[Tuple[str, str], Set[str]]
for parent, child in edges:
for method in methods[(parent, child)]:
module_methods[child].add(method)
callers[(child, method)].add(parent)
for child in sorted(child_modules):
# Since children are found using the regex pattern, it is difficult
# to know if they are JS or TS files without checking which files
# exist. Instead, just print the name of the module.
print(child)
for method in module_methods[child]:
print(' ' + child + '.' + method)
for caller in sorted(callers[(child, method)]):
print(' ' + caller)
print()
print()
def produce_partial_output(graph: Graph) -> None:
print(graph.num_edges())
buffer = make_dot_file(graph)
graph.report()
with open(OUTPUT_FILE_PATH, 'w') as f:
f.write(buffer)
subprocess.check_call(["dot", "-Tpng", OUTPUT_FILE_PATH, "-o", PNG_FILE_PATH])
print()
print('See dot file here: {}'.format(OUTPUT_FILE_PATH))
print('See output png file: {}'.format(PNG_FILE_PATH))
def run() -> None:
edges, methods = get_js_edges()
graph = Graph(edges)
graph, removed_edges = find_edges_to_remove(graph, methods)
if graph.num_edges() == 0:
report_roadmap(removed_edges, methods)
else:
produce_partial_output(graph)
if __name__ == '__main__':
run()