| #!/usr/bin/env python3 |
| # Copyright 2020 The Fuchsia Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| """Compare the outputs of GN and ZN toolchains when building the same canary |
| targets. In case of difference, an error message will explain the issue, and |
| the content of OUTPUT_DIR can be manually reviewed by a human to look at |
| differences. |
| |
| This scripts must be run from the top-level Fuchsia directory, it will work |
| as follows: |
| |
| 1) Create two directories (out/toolchains.gn and out/toolchains.zn by default) |
| and create an args.gn in each one of them, to be parse by the Fuchsia and |
| Zircon build respectively, directing the graph to the canary targets. |
| |
| 2) Invoke 'gn gen' on both directories to invoke the Fuchsia and Zircon |
| builds, respectively. |
| |
| 3) For a specific set of GN/ZN toolchain pairs, compare the content of |
| their respective toolchain.ninja file to verify that they use the same |
| tool() definitions. |
| |
| 4) For each canary target, Invoke 'ninja -C <dir> -t commands <target>' to |
| get the list of commands used to build the target, process the result |
| slightly to account for differences between the two build systems, |
| then compare the results. |
| |
| 5) Populate OUTPUT_DIR/{gn,zn}/<toolchain>/ directories with `rules.json` |
| file that corresponds to the toolchain.ninja file for each toolchain, |
| as well as `<target>.commands` containing a prettified list of commands |
| for each target. |
| |
| This makes it easy to review differences manually, especially with a |
| graphical tool, e.g. "meld OUTPUT_DIR/zn OUTPUT_DIR/gn" will give a |
| very useful overview of the differences. |
| """ |
| |
| import argparse |
| import difflib |
| import json |
| import os |
| import platform |
| import shutil |
| import subprocess |
| import sys |
| |
| # The prefix of the two directories where ninja build files will be generated, |
| # related to $FUCHSIA_DIR/out/. This script will populate |
| # $FUCSHIA_DIR/out/$PREFIX.zn and $FUCHSIA_DIR/out/$PREFIX.gn and compare |
| # their content. |
| |
| _DEFAULT_OUT_PREFIX = 'toolchains' |
| |
| # The list of toolchains to compare. For now, this is a list of scopes |
| # with the following schema: |
| # |
| # name: Name for this toolchain pair / comparison. Will be used |
| # to create an $OUTPUT_DIR/{gn,zn}/$NAME directory where processed |
| # outputs will be stored for human review. |
| # |
| # gn.toolchain: GN-build toolchain label. |
| # gn.output_dir: Output sub-directory for executables generated by the GN |
| # toolchain. Relative to the GN root build directory. |
| # |
| # zn.toolchain: ZN-build toolchain label. |
| # zn.output_dir: Output sub-directory for executables generated by the ZN |
| # toolchain. Relative to the ZN root build directory. |
| # |
| # output_extension: [optional] Executable extension for this toolchain, |
| # default is empty (no extension). |
| # |
| # no_shared: [optional] Boolean set to True if these toolchains do not |
| # support building shared libraries. Default is False. |
| # |
| _ALL_TOOLCHAINS = [ |
| { |
| 'name': 'host_x64', |
| 'gn': |
| { |
| 'toolchain': '//build/toolchain:host_x64', |
| 'output_dir': 'host_x64', |
| }, |
| 'zn': |
| { |
| 'toolchain': '//public/gn/toolchain:host-x64-linux-clang', |
| 'output_dir': 'host-x64-linux-clang/obj/public/canaries/', |
| }, |
| 'no_shared': True, |
| }, |
| ] |
| |
| _GN_TOOLCHAINS = [e['gn']['toolchain'] for e in _ALL_TOOLCHAINS] |
| |
| _ZN_TOOLCHAINS = [e['zn']['toolchain'] for e in _ALL_TOOLCHAINS] |
| |
| # The list of GN tool names that needs to be compared. The others are ignored |
| # by this script (e.g. Objective-C, Rust and Copy + Stamp). |
| _COMMON_TOOLS = {'alink', 'link', 'asm', 'cc', 'cxx', 'solink', 'solink_module'} |
| |
| # The Clang binary directory, as it should appear in build commands. |
| _CLANG_BINPREFIX = '../../prebuilt/third_party/clang/linux-x64/bin' |
| |
| |
| def _recreate_directory(dir_path): |
| """Create or cleanup |dir_path| directory path.""" |
| if os.path.isdir(dir_path): |
| shutil.rmtree(dir_path) |
| os.makedirs(dir_path) |
| |
| |
| def _write_file(path, data): |
| """Write |data| to file |path|.""" |
| with open(path, 'w') as f: |
| f.write(data) |
| |
| |
| def _cmd_output(cmds): |
| """Return shell command output as a string.""" |
| return subprocess.check_output(cmds).decode('utf-8').rstrip() |
| |
| |
| def _write_dict_as_json(json_file, rules): |
| """Write dictionary as a JSON file.""" |
| with open(json_file, 'w') as f: |
| json.dump(rules, f, indent=2, sort_keys=True) |
| |
| |
| def _generate_gn_args_for_gn_build(): |
| """Generate the args.gn file used by the GN canary build.""" |
| result = '# Auto-generated - DO NOT EDIT\n' |
| result += 'base_package_labels = []\n' |
| result += 'cache_package_labels = []\n' |
| result += 'universe_package_labels = [\n' |
| for toolchain in _GN_TOOLCHAINS: |
| result += ' "//zircon/public/canaries:canaries(%s)",' % toolchain |
| result += ']\n' |
| return result |
| |
| |
| def _generate_gn_args_for_zn_build(): |
| """Generate the args.gn file used by the ZN canary build.""" |
| result = '# Auto-generated - DO NOT EDIT\n' |
| result += "default_deps = [\n" |
| for toolchain in _ZN_TOOLCHAINS: |
| result += ' "//public/canaries:canaries(%s)",' % toolchain |
| result += "]\n" |
| return result |
| |
| |
| def parse_toolchain_ninja_file(toolchain_ninja, toolchain): |
| """Parse the content of a toolchain.ninja file. |
| |
| Parse the content of a toolchain.ninja file, and extract its tool-specific |
| entries (e.g. ${toolchain}_alink, ${toolchain}_link, etc). |
| Ignoring the rest. |
| |
| Args: |
| toolchain_ninja: A string holding the toolchain.ninja file content. |
| toolchain: The toolchain's name, will be replaced by 'TOOLCHAIN' |
| in the output. |
| |
| Returns: |
| A { tool_name -> tool_dict } dictionary, where |tool_name| is a GN |
| tool() name, and tool_dict() is a dictionary of the corresponding |
| entries from the input data, with "${toolchain}" replaced with |
| 'TOOLCHAIN'. E.g.: |
| |
| { |
| "alink": { |
| "command": "......", |
| "description": "....", |
| "rspfile": "....", |
| ... |
| }, |
| ... |
| } |
| """ |
| content = toolchain_ninja.replace(toolchain, 'TOOLCHAIN') |
| tool_name = None |
| tool_rule_prefix = 'rule TOOLCHAIN_' |
| result = {} |
| |
| for line in content.splitlines(): |
| line = line.rstrip() |
| if not line: |
| # Empty lines exit the tool section. |
| tool_name = None |
| continue |
| |
| if line[0] != ' ': |
| # Expected format for the start of a tool section: |
| # rule ${toolchain}_${tool}. |
| if line.startswith(tool_rule_prefix): |
| tool_name = line[len(tool_rule_prefix):] |
| result[tool_name] = {} |
| continue |
| elif tool_name: |
| # Expected format for a tool section line: |
| # <key> = <value> |
| key, separator, value = line.partition('=') |
| assert separator == '=', 'Unknown tool section line: ' + line |
| key = key.strip() |
| value = value.strip() |
| result[tool_name][key] = value |
| continue |
| |
| # Something else, ignore and exit tool section if any. |
| tool_name = None |
| |
| return result |
| |
| |
| def _remove_unnecessary_tools(rules): |
| """Remove any un-needed tools from a set of toolchain rules. |
| |
| Args: |
| rules: A { tool name -> tool dict } dictionary as returned by |
| parse_toolchain_ninja_file(). |
| Returns: |
| The input dictionary, but only with the keys from _COMMON_TOOLS. |
| """ |
| return {k: v for k, v in rules.items() if k in _COMMON_TOOLS} |
| |
| |
| def _load_toolchain_ninja(out_dir, toolchain_label): |
| """Load a toolchain.ninja path for a given toolchain. |
| |
| Args: |
| out_dir: Root output directory (e.g. "$FUCHSIA_DIR/out/toolchains.gn") |
| toolchain: Toolchain GN label (e.g. "//build/toolchain:host_x64"). |
| |
| Returns: |
| A (content, toolchain) tuple, where |content| is a string containing |
| the file's content, and |toolchain| is the toolchain's name |
| (e.g. "host_x64"). |
| """ |
| _, _, toolchain_name = toolchain_label.partition(':') |
| toolchain_ninja_path = os.path.join( |
| out_dir, toolchain_name, 'toolchain.ninja') |
| with open(toolchain_ninja_path, 'r') as f: |
| toolchain_ninja = f.read() |
| return (toolchain_ninja, toolchain_name) |
| |
| |
| def pretty_print_commands_list(commands): |
| """Convert a list of commands into a pretty-printed string.""" |
| result = "" |
| for cmd in commands: |
| cmd0, _, _ = cmd.partition(' ') |
| is_compile_or_link = ( |
| cmd0.endswith('clang') or cmd0.endswith('clang++') or |
| cmd0.endswith('g++') or cmd0.endswith('gcc')) |
| |
| if is_compile_or_link: |
| # This is a compiler/linker command, use one argument per line. |
| cmd = ' \\\n '.join(cmd.split(' ')) |
| else: |
| # This is a regular command, split at && instead. |
| cmd = '&& \\\n '.join(cmd.split('&&')) |
| |
| result += cmd + '\n' |
| |
| return result |
| |
| |
| def _write_commands_to_file(path, commands): |
| """Write target commands to a file, pretty-printing it to help comparison.""" |
| with open(path, 'w') as f: |
| f.write(pretty_print_commands_list(commands)) |
| |
| |
| def _remove_gn_config_deps_touch_commands(commands): |
| """Remove extra stamp touch commands from GN build. |
| |
| The Fuchsia build adds stamps for config_deps groups, remove |
| them since they are not important for this comparison. |
| They look like: |
| touch TOOLCHAIN/obj/.../${config}_deps.stamp |
| |
| Args: |
| commands: List of command strings from the GN build. |
| Returns: |
| A new list of command strings. |
| """ |
| return [ |
| cmd for cmd in commands if not ( |
| cmd.startswith('touch TOOLCHAIN/') and cmd.endswith('_deps.stamp')) |
| ] |
| |
| |
| def _update_gn_executable_output_directory(commands): |
| """Update the output path of executables and response files. |
| |
| The GN and ZN builds place their executables in different locations |
| so adjust then GN ones to match the ZN ones. |
| |
| Args: |
| commands: list of command strings from the GN build. |
| Returns: |
| A new list of command strings. |
| """ |
| replacements = { |
| ' TOOLCHAIN/main_with_static ': |
| ' TOOLCHAIN/obj/public/canaries/main_with_static ', |
| ' TOOLCHAIN/main_with_static.exe ': |
| ' TOOLCHAIN/obj/public/canaries/main_with_static.exe ', |
| 'TOOLCHAIN/main_with_static.rsp': |
| 'TOOLCHAIN/obj/public/canaries/main_with_static.rsp', |
| } |
| |
| result = [] |
| for cmd in commands: |
| for key, val in replacements.items(): |
| cmd = cmd.replace(key, val) |
| result.append(cmd) |
| |
| return result |
| |
| |
| def _check_toolchain_rules( |
| gn_rules, gn_toolchain, zn_rules, zn_toolchain, verbose): |
| """Check the toolchain rules between a GN toolchain and its ZN equivalent. |
| |
| Args: |
| gn_rules: A { tool -> { key: value } } map, as returned by |
| parse_toolchain_ninja_file(), corresponding to a GN toolchain. |
| gn_toolchain: The GN toolchain label. |
| zn_rules: Same as |gn_rules|, for the corresponding ZN toolchain. |
| zn_toolchain: The ZN toolchain label. |
| verbose: If True, add key value differences to error messages. |
| |
| Returns: |
| A (warnings, errors) pair, where |warnings| and |errors| are list of |
| strings describing warnings or errors found in the comparison. |
| """ |
| |
| warnings = [] |
| errors = [] |
| |
| # Check that all tools in the ZN toolchain are in the GN one. |
| zn_tools = set(zn_rules.keys()) |
| gn_tools = set(gn_rules.keys()) |
| missing_tools = zn_tools - gn_tools |
| if missing_tools: |
| message = 'Missing tools from GN:%s (compared with ZN:%s): %s' % ( |
| gn_toolchain, zn_toolchain, ' '.join(sorted(missing_tools))) |
| errors += [message] |
| |
| # For each tool, check that the keys are the same. |
| # Then check that their values are also identical. |
| for tool in zn_tools: |
| zn_tool = zn_rules[tool] |
| gn_tool = gn_rules[tool] |
| |
| gn_keys = set(gn_tool.keys()) |
| zn_keys = set(zn_tool.keys()) |
| extra_keys = gn_keys - zn_keys |
| if extra_keys: |
| warnings += [ |
| 'GN:%s: extra %s tool keys: %s' % |
| (gn_toolchain, tool, sorted(extra_keys)) |
| ] |
| |
| missing_keys = zn_keys - gn_keys |
| if missing_keys: |
| errors += [ |
| 'GN:%s: Missing %s tool keys: %s' % |
| (gn_toolchain, tool, sorted(missing_keys)) |
| ] |
| |
| for key in gn_keys & zn_keys: |
| zn_value = zn_tool[key] |
| gn_value = gn_tool[key] |
| |
| if zn_value != gn_value: |
| message = 'GN:%s:%s: %s key values differ' % ( |
| gn_toolchain, tool, key) |
| if verbose: |
| message += '\n gn [%s]\n zn [%s]\n' % (gn_value, zn_value) |
| errors += [message] |
| |
| return warnings, errors |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser( |
| description=__doc__, |
| formatter_class=argparse.RawDescriptionHelpFormatter) |
| parser.add_argument( |
| 'output_dir', |
| metavar='OUTPUT_DIR', |
| help=( |
| 'Output directory where processed toolchain rules and commands ' + |
| 'will be written for human review and comparison. Must not exist ' + |
| 'unless --clean is used.')) |
| parser.add_argument( |
| '--clean', |
| action='store_true', |
| help=( |
| 'Cleanup output directory if it already exists, instead of ' + |
| 'aborting.')) |
| parser.add_argument( |
| '--verbose', |
| action='store_true', |
| help='Print differences with error messages.') |
| parser.add_argument( |
| '--root-dir', |
| help='Root Fuchsia source directory. Default is auto-detected.') |
| parser.add_argument( |
| '--gn', |
| help='Path to GN executable. The prebuilt Fuchsia one will be used by ' |
| 'default.') |
| parser.add_argument( |
| '--ninja', |
| help='Path to Ninja executable. The prebuilt Fuchsia one will be used ' |
| 'by default.') |
| parser.add_argument( |
| '--out-prefix', |
| default=_DEFAULT_OUT_PREFIX, |
| help='Prefix of output directories used for comparison, relative to ' + |
| '$ROOT_DIR/out/. Default is [%s]' % _DEFAULT_OUT_PREFIX) |
| parser.add_argument( |
| '--skip-gen', |
| action='store_true', |
| help='Skip \'gn gen\' step. Only used for developing this script.') |
| |
| args = parser.parse_args() |
| if args.root_dir is None: |
| # Assume this script os under //zircon/public/canaries/ |
| root_dir = os.path.abspath( |
| os.path.join(os.path.dirname(__file__), '..', '..', '..')) |
| else: |
| root_dir = args.root_dir |
| |
| assert os.path.isdir(root_dir), 'Missing root directory: ' + root_dir |
| root_dir = os.path.abspath(root_dir) |
| |
| # Create or cleanup output directory if needed. |
| output_dir = os.path.abspath(args.output_dir) |
| if os.path.isdir(output_dir): |
| if not args.clean: |
| print( |
| 'ERROR: Output directory already exists. Consider using ' + |
| '--clean to remove its content and use it.', |
| file=sys.stderr) |
| return 1 |
| _recreate_directory(output_dir) |
| else: |
| os.makedirs(output_dir) |
| |
| host_cpu = platform.machine() |
| host_cpu = { |
| 'x86_64': 'x64', |
| 'aarch64': 'arm64', |
| }.get(host_cpu, host_cpu) |
| |
| host_os = platform.system() |
| host_os = { |
| 'Linux': 'linux', |
| 'Darwin': 'mac', |
| 'Windows': 'win', |
| }.get(host_os, host_os) |
| |
| host_name = '%s-%s' % (host_os, host_cpu) |
| |
| if args.gn: |
| gn_path = args.gn |
| else: |
| gn_path = os.path.join( |
| root_dir, 'prebuilt', 'third_party', 'gn', host_name, 'gn') |
| |
| if args.ninja: |
| ninja_path = args.ninja |
| else: |
| ninja_path = os.path.join( |
| root_dir, 'prebuilt', 'third_party', 'ninja', host_name, 'ninja') |
| |
| gn_out_dir = os.path.join(root_dir, 'out', args.out_prefix + '.gn') |
| zn_out_dir = os.path.join(root_dir, 'out', args.out_prefix + '.zn') |
| |
| if not args.skip_gen: |
| # Generate the $ROOT_DIR/out/$PREFIX.gn/args.gn |
| print('GN_OUT_DIR=' + gn_out_dir) |
| _recreate_directory(gn_out_dir) |
| _write_file( |
| os.path.join(gn_out_dir, 'args.gn'), |
| _generate_gn_args_for_gn_build()) |
| |
| # Populate $ROOT_DIR/out/$PREFIX.gn now. |
| subprocess.check_call( |
| [gn_path, 'gen', |
| os.path.relpath(gn_out_dir, start=root_dir)], |
| cwd=root_dir) |
| |
| # Generate $ROOT_DIR/out/$PREFIX.zn/args.gn |
| print('ZN_OUT_DIR=' + zn_out_dir) |
| _recreate_directory(zn_out_dir) |
| _write_file( |
| os.path.join(zn_out_dir, 'args.gn'), |
| _generate_gn_args_for_zn_build()) |
| |
| # Populate $ROOT_DIR/out/$PREFIX.zn now |
| subprocess.check_call( |
| [ |
| gn_path, 'gen', '--root=zircon', |
| os.path.relpath(zn_out_dir, start=root_dir) |
| ], |
| cwd=root_dir) |
| |
| for t in _ALL_TOOLCHAINS: |
| gn_toolchain = t['gn']['toolchain'] |
| zn_toolchain = t['zn']['toolchain'] |
| |
| gn_toolchain_ninja, gn_toolchain_name = _load_toolchain_ninja( |
| gn_out_dir, gn_toolchain) |
| |
| gn_toolchain_rules = parse_toolchain_ninja_file( |
| gn_toolchain_ninja, gn_toolchain_name) |
| |
| zn_toolchain_ninja, zn_toolchain_name = _load_toolchain_ninja( |
| zn_out_dir, zn_toolchain) |
| |
| zn_toolchain_rules = parse_toolchain_ninja_file( |
| zn_toolchain_ninja, zn_toolchain_name) |
| |
| gn_toolchain_rules = _remove_unnecessary_tools(gn_toolchain_rules) |
| zn_toolchain_rules = _remove_unnecessary_tools(zn_toolchain_rules) |
| |
| # All outputs for this toolchain pair will be in this directory. |
| gn_output_dir = os.path.join(output_dir, 'gn', t['name']) |
| zn_output_dir = os.path.join(output_dir, 'zn', t['name']) |
| os.makedirs(gn_output_dir) |
| os.makedirs(zn_output_dir) |
| |
| _write_dict_as_json( |
| os.path.join(gn_output_dir, 'rules.json'), gn_toolchain_rules) |
| |
| _write_dict_as_json( |
| os.path.join(zn_output_dir, 'rules.json'), zn_toolchain_rules) |
| |
| warnings, errors = _check_toolchain_rules( |
| gn_toolchain_rules, gn_toolchain, zn_toolchain_rules, zn_toolchain, |
| args.verbose) |
| |
| for w in warnings: |
| print('WARNING: %s' % w, file=sys.stderr) |
| |
| # Compare output commands for canary targets now. |
| targets = ['main_with_static'] |
| if not t.get('no_shared', False): |
| targets += ['main_with_shared'] |
| |
| extension = "" |
| if 'output_extension' in t: |
| extension = "." + t['output_extension'] |
| |
| for target in targets: |
| gn_outdir = t['gn']['output_dir'] |
| zn_outdir = t['zn']['output_dir'] |
| |
| is_shared = target.endswith('shared') |
| if is_shared: |
| gn_outdir += "-shared" |
| zn_outdir += ".shlib" |
| |
| gn_outfile = os.path.join(gn_outdir, target) + extension |
| zn_outfile = os.path.join(zn_outdir, target) + extension |
| print( |
| '%s: Comparing commands for GN [%s] and ZN [%s]' % |
| (t['name'], gn_outfile, zn_outfile)) |
| |
| gn_commands = _cmd_output( |
| [ninja_path, '-C', gn_out_dir, '-t', 'commands', gn_outfile]) |
| |
| zn_commands = _cmd_output( |
| [ninja_path, '-C', zn_out_dir, '-t', 'commands', zn_outfile]) |
| |
| # Replace toolchain name with TOOLCHAIN |
| gn_commands = gn_commands.replace( |
| gn_toolchain_name + '/', 'TOOLCHAIN/') |
| zn_commands = zn_commands.replace( |
| zn_toolchain_name + '/', 'TOOLCHAIN/') |
| |
| # Remove /zircon/ sub-path from GN build commands. |
| gn_commands = gn_commands.replace('/obj/zircon/', '/obj/').replace( |
| '/gen/zircon/', '/gen/') |
| |
| # Replace out/toolchain.{gn,zn} with BUILD_ROOT_DIR |
| gn_commands = gn_commands.replace( |
| 'out/%s.gn' % args.out_prefix, 'BUILD_ROOT_DIR') |
| zn_commands = zn_commands.replace( |
| 'out/%s.zn' % args.out_prefix, 'BUILD_ROOT_DIR') |
| |
| # Split lines. |
| gn_commands = gn_commands.splitlines() |
| zn_commands = zn_commands.splitlines() |
| |
| # Sanitize GN commands a little. |
| gn_commands = _remove_gn_config_deps_touch_commands(gn_commands) |
| gn_commands = _update_gn_executable_output_directory(gn_commands) |
| |
| if len(gn_commands) != len(zn_commands): |
| errors += [ |
| '%s: GN(%s) vs ZN(%s) line count mismatch: %d vs %d' % ( |
| target, gn_toolchain_name, zn_toolchain_name, |
| len(gn_commands), len(zn_commands)) |
| ] |
| |
| # Compare commands list. |
| differences = list( |
| difflib.unified_diff(gn_commands, zn_commands, n=2)) |
| if len(differences) > 0: |
| message = ( |
| '%s: GN(%s) vs ZN(%s) have different content!' % |
| (target, gn_toolchain_name, zn_toolchain_name)) |
| if args.verbose: |
| message += '\n %s' % '\n '.join(differences) |
| |
| errors += [message] |
| |
| _write_commands_to_file( |
| os.path.join(gn_output_dir, '%s.commands' % target), |
| gn_commands) |
| |
| _write_commands_to_file( |
| os.path.join(zn_output_dir, '%s.commands' % target), |
| zn_commands) |
| |
| if errors: |
| for e in errors: |
| print('ERROR: %s' % e, file=sys.stderr) |
| print('For full details, see the content of: %s' % output_dir) |
| return 1 |
| |
| print('OK.') |
| return 0 |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |