| #!/usr/bin/env fuchsia-vendored-python | |
| # Copyright 2026 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. | |
| """Trims the visibility allowlist of a Bazel, dual-built or GN target. | |
| Example usage: | |
| $ fx set ... | |
| $ build/bazel/scripts/trim_visibility.py --target=//zircon/system/ulib/svc:svc | |
| This is the Bazel-aware counterpart of scripts/gn/trim_visibility.py. It finds | |
| the direct consumers of a target in both build graphs and reports, for each | |
| entry of the target's visibility list, whether it is still needed: | |
| * Bazel consumers are the result of | |
| `fx bazel query --keep_going 'rdeps(<universe>, <target>, 1)'`. | |
| The universe defaults to `//...`. A dependency added by a symbolic macro | |
| is checked against the package that defines the macro, so consumers | |
| whose own package is not covered are also matched against the packages | |
| of the .bzl files that instantiated them. | |
| * GN consumers are the result of `fx gn refs <build_dir> <target>` for | |
| every toolchain the corresponding GN target is defined in. This is only | |
| done if a GN target with the same label exists, e.g. because it is | |
| generated by bazel2gn or because the target was not migrated yet. | |
| The GN graph only contains the BUILD.gn files loaded by the current `fx set` | |
| configuration. So an entry that covers no consumer is kept as "unverified" if | |
| a build file in its scope references the target textually (`git grep`), or if | |
| its scope contains BUILD.gn files that the current GN build does not load. | |
| Build files that reference the target but are not covered by the list are | |
| reported too: they are dead code, latent visibility errors, or false | |
| positives. | |
| Limitations: | |
| * A BUILD.gn file outside of the list that is not loaded by the current | |
| GN build and depends on the target only through a template is not | |
| reported (it would be a latent visibility error anyway). | |
| * `gn refs` does not report configs that reference a config through their | |
| own `configs`. | |
| * Entries for packages outside of fuchsia.git (e.g. //vendor/...) are | |
| kept as "unknown". | |
| * The textual search does not look into git submodules (e.g. most of | |
| //third_party); unused entries there are only kept if their BUILD.gn | |
| files are not loaded by the current GN build. | |
| When the BUILD.bazel `visibility` attribute carries a bazel2gn overwrite | |
| annotation (e.g. `# @bazel2gn:raw_overwrite:[ "//*" ]`), the Bazel and GN | |
| lists are different on purpose: the Bazel list is checked against Bazel | |
| consumers only, and the GN list (as reported by `fx gn desc`) against GN | |
| consumers only. | |
| The trimmed lists are printed in the syntax of the build file that contains | |
| them: Bazel syntax (`//foo:__pkg__`, `//foo:__subpackages__`) for BUILD.bazel | |
| and GN syntax (`//foo:*`, `//foo/*`) for BUILD.gn or bazel2gn annotations. | |
| For GN-only targets, a Bazel equivalent is printed as well, for use when | |
| migrating the target to Bazel. | |
| To run this script's tests: | |
| $ fx test //build/bazel/scripts:trim_visibility_test | |
| """ | |
| import argparse | |
| import ast | |
| import concurrent.futures | |
| import dataclasses | |
| import enum | |
| import os | |
| import re | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tempfile | |
| from typing import Callable, Iterable, Sequence | |
| # Marker line that separates hand-written GN targets from bazel2gn generated | |
| # targets in a BUILD.gn file. | |
| BAZEL2GN_SENTINEL = "## BAZEL2GN SENTINEL - DO NOT EDIT BELOW THIS LINE ##" | |
| # Prefix of bazel2gn comment annotations. | |
| BAZEL2GN_ANNOTATION = "@bazel2gn:" | |
| # bazel2gn annotations that do not change the converted visibility values. | |
| _NON_OVERWRITE_ANNOTATIONS = ("transformer=",) | |
| GRAPH_BAZEL = "bazel" | |
| GRAPH_GN = "gn" | |
| # Number of consumers to print per entry, unless --verbose is used. | |
| _MAX_EXAMPLES = 3 | |
| class Error(Exception): | |
| """An error reported to the user without a stack trace.""" | |
| # --------------------------------------------------------------------------- | |
| # Labels. | |
| # --------------------------------------------------------------------------- | |
| @dataclasses.dataclass(frozen=True, order=True) | |
| class Label: | |
| """A label in the main repository, e.g. //foo/bar:baz. | |
| `package` has no leading `//` and is empty for the root package. | |
| """ | |
| package: str | |
| name: str | |
| def __str__(self) -> str: | |
| return f"//{self.package}:{self.name}" | |
| def strip_toolchain(label: str) -> str: | |
| """Removes a GN toolchain suffix, e.g. `//foo:bar(//tc:x64)` -> `//foo:bar`.""" | |
| if label.endswith(")") and "(" in label: | |
| return label[: label.index("(")] | |
| return label | |
| def parse_label(text: str, current_package: str | None = None) -> Label: | |
| """Parses an absolute or package-relative Bazel or GN label. | |
| Raises: | |
| ValueError: if `text` is not a label in the main repository. | |
| """ | |
| label = strip_toolchain(text.strip()) | |
| # Canonical labels of the main repository, e.g. `@//foo` or `@@//foo`. | |
| if label.startswith("@@//"): | |
| label = label[2:] | |
| elif label.startswith("@//"): | |
| label = label[1:] | |
| if label.startswith("@"): | |
| raise ValueError(f"Not a label in the main repository: {text}") | |
| if label.startswith("//"): | |
| package, sep, name = label[2:].partition(":") | |
| package = package.rstrip("/") | |
| if package == ".": | |
| package = "" | |
| if not sep: | |
| name = package.rpartition("/")[2] | |
| elif label.startswith(":"): | |
| if current_package is None: | |
| raise ValueError(f"Relative label without a package: {text}") | |
| package, name = current_package, label[1:] | |
| else: | |
| raise ValueError(f"Not an absolute or relative label: {text}") | |
| if not name: | |
| raise ValueError(f"Invalid label: {text}") | |
| return Label(package, name) | |
| def is_in_package_tree(package: str, root: str) -> bool: | |
| """Whether `package` is `root` or one of its subpackages.""" | |
| return not root or package == root or package.startswith(root + "/") | |
| # --------------------------------------------------------------------------- | |
| # Visibility entries. | |
| # --------------------------------------------------------------------------- | |
| class Kind(enum.Enum): | |
| PUBLIC = "public" | |
| PRIVATE = "private" | |
| # All targets in `package`. | |
| PACKAGE = "package" | |
| # All targets in `package` and its subpackages. | |
| SUBPACKAGES = "subpackages" | |
| # A single target (GN only). | |
| TARGET = "target" | |
| # A Bazel package_group (Bazel only). | |
| PACKAGE_GROUP = "package_group" | |
| @dataclasses.dataclass(frozen=True) | |
| class Entry: | |
| """A visibility entry, independent of the Bazel or GN syntax used.""" | |
| kind: Kind | |
| package: str = "" | |
| name: str = "" | |
| @property | |
| def label(self) -> Label: | |
| return Label(self.package, self.name) | |
| PUBLIC = Entry(Kind.PUBLIC) | |
| PRIVATE = Entry(Kind.PRIVATE) | |
| def parse_bazel_entry(text: str, own_package: str) -> Entry: | |
| """Parses an entry of a Bazel `visibility` attribute.""" | |
| if text == "//visibility:public": | |
| return PUBLIC | |
| if text == "//visibility:private": | |
| return PRIVATE | |
| label = parse_label(text, own_package) | |
| if label.name == "__pkg__": | |
| return Entry(Kind.PACKAGE, label.package) | |
| if label.name == "__subpackages__": | |
| return Entry(Kind.SUBPACKAGES, label.package) | |
| return Entry(Kind.PACKAGE_GROUP, label.package, label.name) | |
| def parse_gn_entry(text: str, own_package: str) -> Entry: | |
| """Parses an entry of a GN `visibility` list (see `gn help visibility`).""" | |
| pattern = strip_toolchain(text.strip()) | |
| if pattern == "*": | |
| return PUBLIC | |
| if pattern == "./*": | |
| return Entry(Kind.SUBPACKAGES, own_package) | |
| if pattern == ":*": | |
| return Entry(Kind.PACKAGE, own_package) | |
| if pattern.startswith("//") and pattern.endswith("/*"): | |
| return Entry(Kind.SUBPACKAGES, pattern[2:-2]) | |
| if pattern == "//*": | |
| return Entry(Kind.SUBPACKAGES, "") | |
| if pattern.endswith(":*"): | |
| return Entry(Kind.PACKAGE, parse_label(pattern, own_package).package) | |
| label = parse_label(pattern, own_package) | |
| return Entry(Kind.TARGET, label.package, label.name) | |
| def entry_to_bazel(entry: Entry, own_package: str) -> str | None: | |
| """Renders an entry in Bazel syntax. | |
| Returns None for entries that only cover the own package, since Bazel | |
| targets are always visible to their own package. GN single-target entries | |
| are widened to their package, because Bazel has no per-target visibility. | |
| """ | |
| if entry.kind == Kind.PUBLIC: | |
| return "//visibility:public" | |
| if entry.kind == Kind.PRIVATE: | |
| return "//visibility:private" | |
| if entry.kind in (Kind.PACKAGE, Kind.TARGET): | |
| if entry.package == own_package: | |
| return None | |
| return f"//{entry.package}:__pkg__" | |
| if entry.kind == Kind.SUBPACKAGES: | |
| return f"//{entry.package}:__subpackages__" | |
| return str(entry.label) | |
| def entry_to_gn(entry: Entry, own_package: str) -> str: | |
| """Renders an entry in GN syntax, matching bazel2gn's conversion.""" | |
| if entry.kind == Kind.PUBLIC: | |
| return "*" | |
| if entry.kind == Kind.PRIVATE: | |
| return ":*" | |
| if entry.kind == Kind.PACKAGE: | |
| return ":*" if entry.package == own_package else f"//{entry.package}:*" | |
| if entry.kind == Kind.SUBPACKAGES: | |
| if entry.package == own_package: | |
| return "./*" | |
| return f"//{entry.package}/*" if entry.package else "//*" | |
| return str(entry.label) | |
| def subsumes(outer: Entry, inner: Entry) -> bool: | |
| """Whether everything `inner` covers is also covered by `outer`.""" | |
| if outer == inner: | |
| return True | |
| if inner.kind == Kind.PRIVATE: | |
| return False | |
| if outer.kind == Kind.PUBLIC: | |
| return True | |
| if inner.kind in (Kind.PUBLIC, Kind.PACKAGE_GROUP): | |
| return False | |
| if outer.kind == Kind.SUBPACKAGES: | |
| return is_in_package_tree(inner.package, outer.package) | |
| if outer.kind == Kind.PACKAGE: | |
| return inner.kind == Kind.TARGET and inner.package == outer.package | |
| return False | |
| # --------------------------------------------------------------------------- | |
| # Bazel package groups. | |
| # --------------------------------------------------------------------------- | |
| @dataclasses.dataclass | |
| class PackageGroup: | |
| packages: list[str] | |
| includes: list[Label] | |
| def package_spec_matches(spec: str, package: str) -> bool: | |
| """Whether a package_group `packages` spec (without `-`) matches.""" | |
| if spec in ("public", "//..."): | |
| return True | |
| if spec == "private": | |
| return False | |
| spec = spec.removeprefix("@").removeprefix("@") | |
| if not spec.startswith("//"): | |
| return False | |
| if spec.endswith("/..."): | |
| return is_in_package_tree(package, spec[2:-4]) | |
| return package == spec[2:] | |
| def package_group_contains( | |
| group_label: Label, | |
| package: str, | |
| groups: dict[Label, PackageGroup], | |
| _seen: frozenset[Label] = frozenset(), | |
| ) -> bool | None: | |
| """Whether the package_group `group_label` contains `package`. | |
| Returns None if the group, or a group it includes, could not be resolved. | |
| """ | |
| group = groups.get(group_label) | |
| if group is None or group_label in _seen: | |
| return None | |
| positive = [s for s in group.packages if not s.startswith("-")] | |
| negative = [s[1:] for s in group.packages if s.startswith("-")] | |
| if any(package_spec_matches(s, package) for s in positive) and not any( | |
| package_spec_matches(s, package) for s in negative | |
| ): | |
| return True | |
| unknown = False | |
| for include in group.includes: | |
| result = package_group_contains( | |
| include, package, groups, _seen | {group_label} | |
| ) | |
| if result: | |
| return True | |
| if result is None: | |
| unknown = True | |
| return None if unknown else False | |
| def covers( | |
| entry: Entry, consumer: Label, groups: dict[Label, PackageGroup] | |
| ) -> bool | None: | |
| """Whether `entry` makes a target visible to `consumer`. | |
| Returns None if this cannot be determined (unresolved package_group). | |
| """ | |
| if entry.kind == Kind.PUBLIC: | |
| return True | |
| if entry.kind == Kind.PRIVATE: | |
| return False | |
| if entry.kind == Kind.PACKAGE: | |
| return consumer.package == entry.package | |
| if entry.kind == Kind.SUBPACKAGES: | |
| return is_in_package_tree(consumer.package, entry.package) | |
| if entry.kind == Kind.TARGET: | |
| return consumer == entry.label | |
| return package_group_contains(entry.label, consumer.package, groups) | |
| # --------------------------------------------------------------------------- | |
| # Parsing tool outputs and build files. | |
| # --------------------------------------------------------------------------- | |
| _STRING_RE = re.compile(r'"((?:[^"\\]|\\.)*)"') | |
| def _string_list_attr(build_output: str, attr: str) -> list[str] | None: | |
| """Extracts a string list attribute from `bazel query --output=build`.""" | |
| match = re.search( | |
| rf"^\s*{re.escape(attr)} = \[(.*?)\]", build_output, re.MULTILINE | |
| ) | |
| if match is None: | |
| return None | |
| return _STRING_RE.findall(match.group(1)) | |
| @dataclasses.dataclass | |
| class BazelRuleInfo: | |
| kind: str | |
| # Effective visibility, including entries added by macros. None if the | |
| # rule does not set it explicitly. | |
| visibility: list[str] | None | |
| # Name of the target passed to the outermost macro, if any. | |
| generator_name: str | None | |
| def parse_bazel_build_output(text: str) -> BazelRuleInfo | None: | |
| """Parses `bazel query --output=build` for a single rule.""" | |
| kind_match = re.search(r"^(\w+)\($", text, re.MULTILINE) | |
| if kind_match is None: | |
| return None | |
| generator = re.search(r'^\s*generator_name = "([^"]*)"', text, re.MULTILINE) | |
| return BazelRuleInfo( | |
| kind=kind_match.group(1), | |
| visibility=_string_list_attr(text, "visibility"), | |
| generator_name=generator.group(1) if generator else None, | |
| ) | |
| def parse_package_group_build_output(text: str) -> PackageGroup | None: | |
| """Parses `bazel query --output=build` for a package_group.""" | |
| info = parse_bazel_build_output(text) | |
| if info is None or info.kind != "package_group": | |
| return None | |
| includes = [] | |
| for include in _string_list_attr(text, "includes") or []: | |
| try: | |
| includes.append(parse_label(include)) | |
| except ValueError: | |
| pass | |
| return PackageGroup(_string_list_attr(text, "packages") or [], includes) | |
| def parse_macro_locations( | |
| build_output: str, labels: Iterable[Label] | |
| ) -> dict[Label, list[str]]: | |
| """Finds the packages of the .bzl files that instantiated each rule. | |
| Args: | |
| build_output: `bazel query --output=build` for several rules. | |
| labels: The labels of these rules. | |
| Returns: | |
| For each rule instantiated by a macro defined in the main repository, | |
| the packages of the .bzl files in its instantiation stack, innermost | |
| last. Visibility of a dependency introduced by a symbolic macro is | |
| checked against the package that defines the macro, not against the | |
| package of the instantiated target. | |
| """ | |
| by_name: dict[str, list[Label]] = {} | |
| for label in sorted(labels, key=lambda l: len(l.package), reverse=True): | |
| by_name.setdefault(label.name, []).append(label) | |
| result: dict[Label, list[str]] = {} | |
| build_dir: str | None = None | |
| current: Label | None = None | |
| workspace: str | None = None | |
| in_stack = False | |
| for line in build_output.splitlines(): | |
| header = re.match(r"^# (/\S+)/BUILD(?:\.bazel)?:\d+:\d+$", line) | |
| if header: | |
| build_dir = header.group(1) | |
| current = None | |
| workspace = None | |
| in_stack = False | |
| continue | |
| name = re.match(r'^ name = "([^"]*)",$', line) | |
| if name and build_dir is not None and current is None: | |
| for label in by_name.get(name.group(1), []): | |
| suffix = "/" + label.package if label.package else "" | |
| if build_dir.endswith(suffix): | |
| current = label | |
| workspace = build_dir[: len(build_dir) - len(suffix)] | |
| break | |
| continue | |
| if re.match(r"^# Rule \S+ instantiated at", line): | |
| in_stack = True | |
| continue | |
| if re.match(r"^# Rule \S+ defined at", line): | |
| in_stack = False | |
| continue | |
| frame = re.match(r"^#\s+(/\S+\.bzl):\d+:\d+\s+in ", line) | |
| if in_stack and frame and current is not None and workspace: | |
| path = frame.group(1) | |
| if path.startswith(workspace + "/"): | |
| package = os.path.dirname(path[len(workspace) + 1 :]) | |
| locations = result.setdefault(current, []) | |
| if package not in locations: | |
| locations.append(package) | |
| return result | |
| @dataclasses.dataclass | |
| class SourceVisibility: | |
| """The `visibility` attribute of a target as written in BUILD.bazel.""" | |
| # Whether the target sets `visibility` explicitly. | |
| present: bool | |
| # The string values, or None if they could not be statically resolved. | |
| entries: list[str] | None = None | |
| # bazel2gn annotations that change the converted GN visibility, e.g. | |
| # `raw_overwrite:[ "//*" ]`. | |
| overwrites: list[str] = dataclasses.field(default_factory=list) | |
| def _comment_annotations(lines: Sequence[str]) -> list[str]: | |
| annotations = [] | |
| for line in lines: | |
| index = line.find("# " + BAZEL2GN_ANNOTATION) | |
| if index < 0: | |
| continue | |
| annotation = line[index + 2 + len(BAZEL2GN_ANNOTATION) :].strip() | |
| if not annotation.startswith(_NON_OVERWRITE_ANNOTATIONS): | |
| annotations.append(annotation) | |
| return annotations | |
| def parse_source_visibility( | |
| build_text: str, target_name: str | |
| ) -> SourceVisibility | None: | |
| """Finds the `visibility` of `target_name` in a BUILD.bazel file. | |
| Returns None if the target or file could not be parsed. Starlark BUILD | |
| files are almost always valid Python syntax, so the `ast` module is used. | |
| """ | |
| try: | |
| module = ast.parse(build_text) | |
| except SyntaxError: | |
| return None | |
| lines = build_text.splitlines() | |
| assignments: dict[str, ast.expr] = {} | |
| calls: list[ast.Call] = [] | |
| for statement in module.body: | |
| if isinstance(statement, ast.Assign): | |
| for assign_target in statement.targets: | |
| if isinstance(assign_target, ast.Name): | |
| assignments[assign_target.id] = statement.value | |
| elif isinstance(statement, ast.Expr) and isinstance( | |
| statement.value, ast.Call | |
| ): | |
| calls.append(statement.value) | |
| def keyword(call: ast.Call, name: str) -> ast.expr | None: | |
| for kw in call.keywords: | |
| if kw.arg == name: | |
| return kw.value | |
| return None | |
| call = None | |
| for candidate in calls: | |
| name = keyword(candidate, "name") | |
| if isinstance(name, ast.Constant) and name.value == target_name: | |
| call = candidate | |
| break | |
| if call is None: | |
| return None | |
| value = keyword(call, "visibility") | |
| if value is None: | |
| return SourceVisibility(present=False) | |
| overwrites: list[str] = [] | |
| def node_lines(node: ast.expr) -> list[str]: | |
| end = node.end_lineno or node.lineno | |
| return lines[node.lineno - 1 : end] | |
| def resolve(node: ast.expr, depth: int = 0) -> list[str] | None: | |
| overwrites.extend(_comment_annotations(node_lines(node))) | |
| if depth > 10: | |
| return None | |
| if isinstance(node, ast.List): | |
| result = [] | |
| for element in node.elts: | |
| if not isinstance(element, ast.Constant) or not isinstance( | |
| element.value, str | |
| ): | |
| return None | |
| result.append(element.value) | |
| return result | |
| if isinstance(node, ast.Name) and node.id in assignments: | |
| return resolve(assignments[node.id], depth + 1) | |
| if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): | |
| left = resolve(node.left, depth + 1) | |
| right = resolve(node.right, depth + 1) | |
| if left is None or right is None: | |
| return None | |
| return left + right | |
| return None | |
| entries = resolve(value) | |
| return SourceVisibility( | |
| present=True, entries=entries, overwrites=sorted(set(overwrites)) | |
| ) | |
| def gn_target_is_generated_by_bazel2gn(build_gn_text: str, name: str) -> bool: | |
| """Whether `name` is defined below the bazel2gn sentinel of a BUILD.gn.""" | |
| _, sentinel, generated = build_gn_text.partition(BAZEL2GN_SENTINEL) | |
| if not sentinel: | |
| return False | |
| return re.search(rf'\(\s*"{re.escape(name)}"\s*\)', generated) is not None | |
| def parse_gn_instances(gn_ls_output: str, target: Label) -> list[str]: | |
| """Selects `target`'s instances (in any toolchain) from `gn ls` output.""" | |
| instances = [] | |
| for line in gn_ls_output.splitlines(): | |
| line = line.strip() | |
| if not line.startswith("//"): | |
| continue | |
| try: | |
| if parse_label(line) == target: | |
| instances.append(line) | |
| except ValueError: | |
| continue | |
| return instances | |
| def parse_labels(lines: Iterable[str]) -> set[Label]: | |
| """Parses labels in the main repository, ignoring anything else.""" | |
| labels = set() | |
| for line in lines: | |
| line = line.strip() | |
| if not line.startswith(("//", "@//", "@@//")): | |
| continue | |
| try: | |
| labels.add(parse_label(line)) | |
| except ValueError: | |
| continue | |
| return labels | |
| def count_query_errors(stderr: str) -> int: | |
| """Counts relevant errors in the output of `bazel query --keep_going`. | |
| References to `@gn_targets`, which is only populated by `fx build`, are | |
| expected and do not hide consumers, so they are not counted. | |
| """ | |
| return sum( | |
| 1 | |
| for line in stderr.splitlines() | |
| if line.startswith("ERROR: ") | |
| and "@gn_targets" not in line | |
| and not line.startswith("ERROR: Evaluation of query") | |
| ) | |
| def parse_ninja_depfile_inputs(depfile_text: str) -> list[str]: | |
| """Returns the inputs listed in a depfile such as build.ninja.d.""" | |
| _, _, inputs = depfile_text.partition(": ") | |
| return inputs.replace("\\\n", " ").split() | |
| def ere_escape(text: str) -> str: | |
| """Escapes `text` for a POSIX extended regular expression.""" | |
| return re.sub(r"([.\[\]()*+?{}|^$\\])", r"\\\1", text) | |
| def label_reference_regex(target: Label) -> str: | |
| """An extended regex matching quoted references to `target`.""" | |
| package = ere_escape("//" + target.package) | |
| name = ere_escape(target.name) | |
| if target.name == target.package.rpartition("/")[2]: | |
| return f'"{package}(:{name})?["(]' | |
| return f'"{package}:{name}["(]' | |
| # Git pathspecs of the GN and Bazel build files. | |
| GN_BUILD_FILES = ("*.gn", "*.gni") | |
| BAZEL_BUILD_FILES = ("*BUILD.bazel", "*/BUILD", "*.bzl") | |
| def is_gn_file(path: str) -> bool: | |
| return path.endswith((".gn", ".gni")) | |
| # GN's `secondary_source` (see //.gn): GN loads | |
| # build/secondary/<package>/BUILD.gn for packages that have no BUILD.gn of | |
| # their own, e.g. //third_party/ogg. | |
| GN_SECONDARY_SOURCE = "build/secondary/" | |
| def build_file_package(path: str) -> str: | |
| """The package that a build file at `path` defines targets in.""" | |
| package = os.path.dirname(path) | |
| if ( | |
| package.startswith(GN_SECONDARY_SOURCE) | |
| and os.path.basename(path) == "BUILD.gn" | |
| ): | |
| return package[len(GN_SECONDARY_SOURCE) :] | |
| return package | |
| def entry_covers_path( | |
| entry: Entry, path: str, groups: dict[Label, PackageGroup] | |
| ) -> bool | None: | |
| """Whether a build file at `path` belongs to a package `entry` covers. | |
| Returns None if this cannot be determined (unresolved package_group). | |
| """ | |
| package = build_file_package(path) | |
| if entry.kind == Kind.TARGET: | |
| return entry.package == package | |
| return covers(entry, Label(package, "*"), groups) | |
| # --------------------------------------------------------------------------- | |
| # Analysis. | |
| # --------------------------------------------------------------------------- | |
| class Status(enum.Enum): | |
| NEEDED = "needed" | |
| UNVERIFIED = "unverified" | |
| UNKNOWN = "unknown" | |
| REDUNDANT = "redundant" | |
| UNUSED = "unused" | |
| @dataclasses.dataclass | |
| class EntryReport: | |
| text: str | |
| entry: Entry | |
| status: Status = Status.UNUSED | |
| consumers: list[Label] = dataclasses.field(default_factory=list) | |
| text_references: list[str] = dataclasses.field(default_factory=list) | |
| subsumed_by: str | None = None | |
| # Why an UNVERIFIED or UNKNOWN entry is kept. | |
| reason: str | None = None | |
| @dataclasses.dataclass | |
| class ListReport: | |
| entries: list[EntryReport] | |
| # Consumers no entry covers. Unless visibility checks were bypassed, these | |
| # are not built in the current configuration or the analysis is wrong. | |
| uncovered: list[Label] | |
| # Consumers for which coverage depends on an unresolved package_group. | |
| undetermined: list[Label] | |
| # Build files that reference the target textually but are not covered by | |
| # the list: dead code, code not built by the current configuration (a | |
| # latent visibility error), or false positives such as comments. | |
| uncovered_references: list[str] = dataclasses.field(default_factory=list) | |
| def kept(self) -> list[EntryReport]: | |
| keep = (Status.NEEDED, Status.UNVERIFIED, Status.UNKNOWN) | |
| return [e for e in self.entries if e.status in keep] | |
| def analyze_list( | |
| entry_texts: Sequence[str], | |
| parse_entry: Callable[[str, str], Entry], | |
| own_package: str, | |
| consumers: Iterable[Label], | |
| implicit_entries: Sequence[Entry] = (), | |
| groups: dict[Label, PackageGroup] | None = None, | |
| reference_files: Sequence[str] | None = None, | |
| check_unused: Callable[[Entry], tuple[Status, str] | None] | None = None, | |
| macro_locations: dict[Label, list[str]] | None = None, | |
| ) -> ListReport: | |
| """Classifies the entries of a visibility list. | |
| Args: | |
| entry_texts: The visibility list, as written. | |
| parse_entry: parse_bazel_entry or parse_gn_entry. | |
| own_package: The package of the target that has the visibility list. | |
| consumers: Direct consumers found in the build graph(s). | |
| implicit_entries: Entries that apply without being written in the | |
| list, e.g. Bazel's implicit own-package visibility or entries | |
| added by a macro. They are never reported or trimmed. | |
| groups: Resolved Bazel package groups. | |
| reference_files: Build files outside of the target's package that | |
| reference the target textually. Entries that cover no consumer | |
| but cover one of these files are kept as "unverified". | |
| check_unused: Called for entries that are still unused after the | |
| text search; can return another status and a reason, e.g. when | |
| the entry covers BUILD.gn files that are not part of the graph. | |
| macro_locations: For Bazel consumers instantiated by macros, the | |
| packages that define these macros. A consumer whose own package | |
| is not covered is matched against these packages instead. | |
| """ | |
| groups = groups or {} | |
| reports = [EntryReport(t, parse_entry(t, own_package)) for t in entry_texts] | |
| uncovered: list[Label] = [] | |
| undetermined: list[Label] = [] | |
| macro_locations = macro_locations or {} | |
| for consumer in sorted(set(consumers)): | |
| # Try the consumer's package first, then the macro packages. | |
| locations = [consumer] + [ | |
| Label(p, consumer.name) for p in macro_locations.get(consumer, []) | |
| ] | |
| found = False | |
| unknown = False | |
| for location in locations: | |
| if any(covers(e, location, groups) for e in implicit_entries): | |
| found = True | |
| break | |
| for report in reports: | |
| result = covers(report.entry, location, groups) | |
| if result: | |
| report.consumers.append(consumer) | |
| found = True | |
| elif result is None: | |
| unknown = True | |
| if found: | |
| break | |
| if not found: | |
| (undetermined if unknown else uncovered).append(consumer) | |
| for report in reports: | |
| if report.consumers: | |
| report.status = Status.NEEDED | |
| elif report.entry.kind == Kind.PACKAGE_GROUP: | |
| # Without a resolved group, its coverage is unknown. | |
| if report.entry.label not in groups: | |
| report.status = Status.UNKNOWN | |
| report.reason = "unresolved package_group" | |
| elif report.entry.kind == Kind.PRIVATE: | |
| report.status = Status.UNKNOWN | |
| report.reason = "not a package" | |
| uncovered_references: list[str] = [] | |
| for path in reference_files or []: | |
| if any(entry_covers_path(e, path, groups) for e in implicit_entries): | |
| continue | |
| results = [ | |
| (r, entry_covers_path(r.entry, path, groups)) for r in reports | |
| ] | |
| if all(result is False for _, result in results): | |
| uncovered_references.append(path) | |
| for report, result in results: | |
| if result and report.status == Status.UNUSED: | |
| report.status = Status.UNVERIFIED | |
| if result and report.status == Status.UNVERIFIED: | |
| report.text_references.append(path) | |
| for report in reports: | |
| if report.status == Status.UNUSED and check_unused is not None: | |
| check = check_unused(report.entry) | |
| if check is not None: | |
| report.status, report.reason = check | |
| # Entries covered by implicit entries or other kept entries are redundant. | |
| for report in reports: | |
| if report.entry.kind == Kind.PRIVATE: | |
| continue | |
| for implicit in implicit_entries: | |
| if subsumes(implicit, report.entry): | |
| report.status = Status.REDUNDANT | |
| report.subsumed_by = "implicit visibility" | |
| break | |
| keep = (Status.NEEDED, Status.UNVERIFIED, Status.UNKNOWN) | |
| kept = [(i, r) for i, r in enumerate(reports) if r.status in keep] | |
| for index, report in enumerate(reports): | |
| if ( | |
| report.entry.kind == Kind.PRIVATE | |
| or report.status == Status.REDUNDANT | |
| ): | |
| continue | |
| for other_index, other in kept: | |
| if other_index == index or not subsumes(other.entry, report.entry): | |
| continue | |
| # For duplicates, keep the first occurrence. | |
| if other.entry == report.entry and other_index > index: | |
| continue | |
| report.status = Status.REDUNDANT | |
| report.subsumed_by = other.text | |
| break | |
| return ListReport(reports, uncovered, undetermined, uncovered_references) | |
| def sorted_labels(values: Iterable[str]) -> list[str]: | |
| """Sorts and dedups labels like buildifier: `//a:x` before `//a/b:x`.""" | |
| return sorted(set(values), key=lambda v: v.split(":")) | |
| def format_bazel_list(values: Sequence[str]) -> str: | |
| if not values: | |
| return 'visibility = ["//visibility:private"],' | |
| lines = ["visibility = ["] | |
| lines += [f' "{v}",' for v in sorted_labels(values)] | |
| lines.append("],") | |
| return "\n".join(lines) | |
| def format_gn_list(values: Sequence[str]) -> str: | |
| lines = ["visibility = ["] | |
| lines += [f' "{v}",' for v in sorted_labels(values)] | |
| lines.append("]") | |
| return "\n".join(lines) | |
| def format_bazel2gn_overwrite(values: Sequence[str]) -> str: | |
| quoted = ", ".join(f'"{v}"' for v in sorted_labels(values)) | |
| return f"# {BAZEL2GN_ANNOTATION}raw_overwrite:[ {quoted} ]" | |
| def gn_list_to_bazel(entries: Sequence[Entry], own_package: str) -> list[str]: | |
| """Converts GN entries to a minimal Bazel visibility list.""" | |
| converted = {} | |
| for entry in entries: | |
| text = entry_to_bazel(entry, own_package) | |
| if text is not None: | |
| converted[text] = parse_bazel_entry(text, own_package) | |
| return sorted_labels( | |
| text | |
| for text, entry in converted.items() | |
| if not any( | |
| other != entry and subsumes(other, entry) | |
| for other in converted.values() | |
| ) | |
| ) | |
| def expected_gn_entries( | |
| bazel_entries: Sequence[Entry], own_package: str | |
| ) -> set[Entry]: | |
| """The GN visibility bazel2gn generates, plus implicit own package.""" | |
| result = set() | |
| for entry in bazel_entries: | |
| if entry.kind == Kind.PRIVATE: | |
| entry = Entry(Kind.PACKAGE, own_package) | |
| result.add(entry) | |
| result.add(Entry(Kind.PACKAGE, own_package)) | |
| return result | |
| # --------------------------------------------------------------------------- | |
| # Running tools. | |
| # --------------------------------------------------------------------------- | |
| class Tools: | |
| """Runs fx, gn, bazel and git commands in a Fuchsia checkout.""" | |
| def __init__(self, fuchsia_dir: str, build_dir: str | None, verbose: bool): | |
| self.fuchsia_dir = fuchsia_dir | |
| self.verbose = verbose | |
| fx = os.path.join(fuchsia_dir, ".jiri_root", "bin", "fx") | |
| if not os.path.exists(fx): | |
| fx = shutil.which("fx") or os.path.join( | |
| fuchsia_dir, "scripts", "fx" | |
| ) | |
| self.fx = fx | |
| self.build_dir = build_dir or self._get_build_dir() | |
| def log(self, message: str) -> None: | |
| if self.verbose: | |
| print(message, file=sys.stderr) | |
| def run( | |
| self, command: list[str], check: bool = True | |
| ) -> subprocess.CompletedProcess[str]: | |
| self.log("+ " + " ".join(command)) | |
| result = subprocess.run( | |
| command, | |
| cwd=self.fuchsia_dir, | |
| capture_output=True, | |
| text=True, | |
| ) | |
| if check and result.returncode != 0: | |
| raise Error( | |
| f"Command failed ({result.returncode}): {' '.join(command)}\n" | |
| f"{result.stderr[-4000:]}" | |
| ) | |
| return result | |
| def _get_build_dir(self) -> str: | |
| build_dir = self.run([self.fx, "get-build-dir"]).stdout.strip() | |
| if not build_dir or not os.path.isdir(build_dir): | |
| raise Error("No build directory, run `fx set` first.") | |
| return build_dir | |
| def gn( | |
| self, args: list[str], check: bool = True | |
| ) -> subprocess.CompletedProcess[str]: | |
| return self.run( | |
| [self.fx, "gn", args[0], self.build_dir] + args[1:], check=check | |
| ) | |
| def bazel_query( | |
| self, args: list[str], check: bool = True | |
| ) -> subprocess.CompletedProcess[str]: | |
| return self.run([self.fx, "bazel", "query"] + args, check=check) | |
| def git_grep_files(self, regex: str, pathspecs: Sequence[str]) -> list[str]: | |
| if not pathspecs: | |
| return [] | |
| result = self.run( | |
| ["git", "grep", "-l", "-E", regex, "--"] + list(pathspecs), | |
| check=False, | |
| ) | |
| if result.returncode not in (0, 1): | |
| raise Error(f"git grep failed: {result.stderr}") | |
| return result.stdout.splitlines() | |
| def git_is_untracked_and_ignored(self, path: str) -> bool: | |
| """Whether `path` is outside fuchsia.git, e.g. //vendor/... | |
| Git submodules are tracked, so they are not considered outside. | |
| """ | |
| if self.run(["git", "ls-files", "--", path]).stdout.strip(): | |
| return False | |
| result = self.run( | |
| ["git", "check-ignore", "-q", "--no-index", path + "/"], | |
| check=False, | |
| ) | |
| return result.returncode == 0 | |
| def gn_loaded_build_files(self) -> set[str] | None: | |
| """Source-relative paths of the GN files loaded by `gn gen`.""" | |
| depfile = os.path.join(self.build_dir, "build.ninja.d") | |
| try: | |
| with open(depfile) as f: | |
| inputs = parse_ninja_depfile_inputs(f.read()) | |
| except OSError: | |
| return None | |
| return { | |
| os.path.relpath( | |
| os.path.normpath(os.path.join(self.build_dir, path)), | |
| self.fuchsia_dir, | |
| ) | |
| for path in inputs | |
| } | |
| @dataclasses.dataclass | |
| class BazelData: | |
| info: BazelRuleInfo | None = None | |
| consumers: set[Label] = dataclasses.field(default_factory=set) | |
| query_errors: int = 0 | |
| groups: dict[Label, PackageGroup] = dataclasses.field(default_factory=dict) | |
| macro_locations: dict[Label, list[str]] = dataclasses.field( | |
| default_factory=dict | |
| ) | |
| @dataclasses.dataclass | |
| class GnData: | |
| instances: list[str] = dataclasses.field(default_factory=list) | |
| visibility: list[str] | None = None | |
| consumers: set[Label] = dataclasses.field(default_factory=set) | |
| def collect_bazel(tools: Tools, target: Label, universe: str) -> BazelData: | |
| data = BazelData() | |
| result = tools.bazel_query(["--output=build", str(target)], check=False) | |
| if result.returncode != 0: | |
| tools.log(f"No Bazel target {target}: {result.stderr.strip()[-500:]}") | |
| return data | |
| data.info = parse_bazel_build_output(result.stdout) | |
| if data.info is None: | |
| return data | |
| # Resolve package groups referenced by the visibility list. | |
| pending = [] | |
| for text in data.info.visibility or []: | |
| entry = parse_bazel_entry(text, target.package) | |
| if entry.kind == Kind.PACKAGE_GROUP: | |
| pending.append(entry.label) | |
| while pending: | |
| label = pending.pop() | |
| if label in data.groups: | |
| continue | |
| group_result = tools.bazel_query( | |
| ["--output=build", str(label)], check=False | |
| ) | |
| group = ( | |
| parse_package_group_build_output(group_result.stdout) | |
| if group_result.returncode == 0 | |
| else None | |
| ) | |
| if group is not None: | |
| data.groups[label] = group | |
| pending.extend(group.includes) | |
| # --keep_going makes the query succeed with partial results (exit code 3) | |
| # when some packages in the universe fail to load. | |
| query = f"rdeps({universe}, {target}, 1)" | |
| result = tools.bazel_query(["--keep_going", query], check=False) | |
| if result.returncode not in (0, 3): | |
| raise Error(f"Bazel query failed: {query}\n{result.stderr[-4000:]}") | |
| data.query_errors = count_query_errors(result.stderr) | |
| if data.query_errors: | |
| tools.log(result.stderr[-4000:]) | |
| data.consumers = parse_labels(result.stdout.splitlines()) - {target} | |
| # Find the macros that instantiated the consumers. The query is passed in | |
| # a file because the list of consumers can be long. | |
| if data.consumers: | |
| labels = " ".join(str(label) for label in sorted(data.consumers)) | |
| with tempfile.NamedTemporaryFile( | |
| "w", prefix="trim_visibility_", suffix=".txt" | |
| ) as query_file: | |
| query_file.write(f"set({labels})") | |
| query_file.flush() | |
| result = tools.bazel_query( | |
| ["--output=build", f"--query_file={query_file.name}"], | |
| check=False, | |
| ) | |
| if result.returncode == 0: | |
| data.macro_locations = parse_macro_locations( | |
| result.stdout, data.consumers | |
| ) | |
| else: | |
| tools.log(f"Could not query consumers: {result.stderr[-2000:]}") | |
| return data | |
| def collect_gn(tools: Tools, target: Label) -> GnData: | |
| data = GnData() | |
| # `gn ls` with a wildcard pattern matches all toolchains, and `gn desc` | |
| # in the default toolchain can run at the same time. | |
| with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: | |
| ls_future = executor.submit( | |
| tools.gn, ["ls", f"//{target.package}:*"], False | |
| ) | |
| desc_future = executor.submit( | |
| tools.gn, ["desc", str(target), "visibility"], False | |
| ) | |
| ls_result = ls_future.result() | |
| desc_result = desc_future.result() | |
| if ls_result.returncode == 0: | |
| data.instances = parse_gn_instances(ls_result.stdout, target) | |
| if not data.instances and desc_result.returncode == 0: | |
| # `gn ls` does not list configs, but `gn desc` and `gn refs` work. | |
| data.instances = [str(target)] | |
| if not data.instances: | |
| tools.log(f"No GN target {target}") | |
| return data | |
| if desc_result.returncode != 0: | |
| # The target is not defined in the default toolchain. | |
| desc_result = tools.gn(["desc", data.instances[0], "visibility"]) | |
| data.visibility = [ | |
| line.strip() for line in desc_result.stdout.splitlines() if line.strip() | |
| ] | |
| # Fully qualified labels so that all toolchains are covered. | |
| refs = tools.gn(["refs"] + data.instances) | |
| data.consumers = parse_labels(refs.stdout.splitlines()) - {target} | |
| return data | |
| # --------------------------------------------------------------------------- | |
| # Reporting. | |
| # --------------------------------------------------------------------------- | |
| def _describe_consumers( | |
| labels: Sequence[Label], graphs: dict[Label, set[str]], verbose: bool | |
| ) -> str: | |
| shown = labels if verbose else labels[:_MAX_EXAMPLES] | |
| parts = [f"{label} [{'+'.join(sorted(graphs[label]))}]" for label in shown] | |
| if len(labels) > len(shown): | |
| parts.append(f"... ({len(labels) - len(shown)} more)") | |
| return "\n ".join(parts) | |
| def _gn_load_note(path: str, gn_loaded: set[str] | None) -> str: | |
| if gn_loaded is not None and is_gn_file(path) and path not in gn_loaded: | |
| return " (not loaded by the current GN build)" | |
| return "" | |
| def print_list_report( | |
| title: str, | |
| report: ListReport, | |
| graphs: dict[Label, set[str]], | |
| gn_loaded: set[str] | None, | |
| verbose: bool, | |
| ) -> None: | |
| print(f"\n=== {title} ===") | |
| by_status: dict[Status, list[EntryReport]] = {s: [] for s in Status} | |
| for entry in report.entries: | |
| by_status[entry.status].append(entry) | |
| def header(status: Status, description: str) -> list[EntryReport]: | |
| entries = by_status[status] | |
| if entries: | |
| print(f"\n{description} ({len(entries)}):") | |
| return entries | |
| for e in header(Status.NEEDED, "Needed"): | |
| print(f' "{e.text}"') | |
| print(f" <- {_describe_consumers(e.consumers, graphs, verbose)}") | |
| for e in header( | |
| Status.UNVERIFIED, | |
| "Unverified, kept: no consumer in the analyzed graphs, but some " | |
| "consumers may be missing from them (check by hand)", | |
| ): | |
| print(f' "{e.text}"') | |
| if e.reason: | |
| print(f" ! {e.reason}") | |
| for path in e.text_references if verbose else e.text_references[:3]: | |
| print(f" ~ {path}{_gn_load_note(path, gn_loaded)}") | |
| for e in header(Status.UNKNOWN, "Unknown, kept"): | |
| print(f' "{e.text}" ({e.reason})') | |
| for e in header(Status.REDUNDANT, "Redundant, remove"): | |
| print(f' "{e.text}" (covered by {e.subsumed_by})') | |
| for e in header(Status.UNUSED, "Unused, remove"): | |
| print(f' "{e.text}"') | |
| if report.undetermined: | |
| print( | |
| f"\nConsumers covered only through unresolved package groups " | |
| f"({len(report.undetermined)}):" | |
| ) | |
| for label in report.undetermined: | |
| print(f" {label} [{'+'.join(sorted(graphs[label]))}]") | |
| if report.uncovered: | |
| print( | |
| f"\nWARNING: consumers NOT covered by the current list " | |
| f"({len(report.uncovered)}); visibility would reject them:" | |
| ) | |
| for label in report.uncovered: | |
| print(f" {label} [{'+'.join(sorted(graphs[label]))}]") | |
| if report.uncovered_references: | |
| print( | |
| f"\nBuild files that reference the target but are NOT covered by " | |
| f"the current list ({len(report.uncovered_references)}): dead " | |
| f"code, code not built by this configuration (a latent visibility " | |
| f"error), or false positives such as comments or other targets' " | |
| f"visibility lists:" | |
| ) | |
| for path in report.uncovered_references: | |
| print(f" ~ {path}{_gn_load_note(path, gn_loaded)}") | |
| def main(argv: Sequence[str] | None = None) -> int: | |
| parser = argparse.ArgumentParser( | |
| description=__doc__, | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| ) | |
| parser.add_argument( | |
| "--target", | |
| required=True, | |
| help="Label of the target with the visibility list, e.g. //foo:bar", | |
| ) | |
| graph = parser.add_mutually_exclusive_group() | |
| graph.add_argument( | |
| "--bazel-only", | |
| action="store_true", | |
| help="Only consider the Bazel target and Bazel consumers.", | |
| ) | |
| graph.add_argument( | |
| "--gn-only", | |
| action="store_true", | |
| help="Only consider the GN target and GN consumers.", | |
| ) | |
| parser.add_argument( | |
| "--bazel-universe", | |
| default="//...", | |
| help="Query expression for the targets to search Bazel consumers in. " | |
| "Default: %(default)s", | |
| ) | |
| parser.add_argument( | |
| "--no-text-search", | |
| action="store_true", | |
| help="Do not search for textual references to the target for entries " | |
| "that cover no consumer in the analyzed graphs.", | |
| ) | |
| parser.add_argument( | |
| "--build-dir", help="Fuchsia build directory. Default: fx get-build-dir" | |
| ) | |
| parser.add_argument( | |
| "--fuchsia-dir", | |
| default=os.environ.get( | |
| "FUCHSIA_DIR", | |
| os.path.join( | |
| os.path.dirname(os.path.abspath(__file__)), "../../.." | |
| ), | |
| ), | |
| help="Fuchsia source directory. Default: $FUCHSIA_DIR", | |
| ) | |
| parser.add_argument( | |
| "--verbose", | |
| action="store_true", | |
| help="Print commands and all consumers per entry.", | |
| ) | |
| args = parser.parse_args(argv) | |
| try: | |
| target = parse_label(args.target) | |
| except ValueError as e: | |
| parser.error(str(e)) | |
| own = target.package | |
| fuchsia_dir = os.path.abspath(args.fuchsia_dir) | |
| tools = Tools(fuchsia_dir, args.build_dir, args.verbose) | |
| print(f"Analyzing {target} ...", file=sys.stderr) | |
| with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: | |
| bazel_future = ( | |
| None | |
| if args.gn_only | |
| else executor.submit( | |
| collect_bazel, tools, target, args.bazel_universe | |
| ) | |
| ) | |
| gn_future = ( | |
| None | |
| if args.bazel_only | |
| else executor.submit(collect_gn, tools, target) | |
| ) | |
| bazel = bazel_future.result() if bazel_future else BazelData() | |
| gn = gn_future.result() if gn_future else GnData() | |
| if bazel.info is None and gn.visibility is None: | |
| raise Error(f"Found neither a Bazel nor a GN target for {target}.") | |
| def read(path: str) -> str | None: | |
| try: | |
| with open(os.path.join(fuchsia_dir, path)) as f: | |
| return f.read() | |
| except OSError: | |
| return None | |
| source = None | |
| if bazel.info is not None: | |
| build_text = read(os.path.join(own, "BUILD.bazel")) or read( | |
| os.path.join(own, "BUILD") | |
| ) | |
| if build_text is not None: | |
| source = parse_source_visibility( | |
| build_text, bazel.info.generator_name or target.name | |
| ) | |
| gn_generated = False | |
| if gn.visibility is not None: | |
| gn_generated = gn_target_is_generated_by_bazel2gn( | |
| read(os.path.join(own, "BUILD.gn")) or "", target.name | |
| ) | |
| graphs: dict[Label, set[str]] = {} | |
| for label in bazel.consumers: | |
| graphs.setdefault(label, set()).add(GRAPH_BAZEL) | |
| for label in gn.consumers: | |
| graphs.setdefault(label, set()).add(GRAPH_GN) | |
| gn_loaded = tools.gn_loaded_build_files() | |
| reference_files: list[str] = [] | |
| if not args.no_text_search: | |
| reference_files = [ | |
| f | |
| for f in tools.git_grep_files( | |
| label_reference_regex(target), | |
| GN_BUILD_FILES + BAZEL_BUILD_FILES, | |
| ) | |
| if build_file_package(f) != own | |
| ] | |
| def first_unloaded_build_gn(entry: Entry) -> str | None: | |
| """A BUILD.gn file in the scope of `entry` that GN did not load.""" | |
| assert gn_loaded is not None | |
| if entry.kind != Kind.SUBPACKAGES: | |
| path = os.path.join(entry.package, "BUILD.gn") | |
| exists = os.path.exists(os.path.join(fuchsia_dir, path)) | |
| return path if exists and path not in gn_loaded else None | |
| # Walk the file system rather than `git ls-files`, so that files in | |
| # git submodules (e.g. under //third_party) are found too. | |
| root = os.path.join(fuchsia_dir, entry.package) | |
| for directory, subdirs, files in os.walk(root): | |
| subdirs[:] = sorted(d for d in subdirs if not d.startswith(".")) | |
| if "BUILD.gn" in files: | |
| path = os.path.relpath( | |
| os.path.join(directory, "BUILD.gn"), fuchsia_dir | |
| ) | |
| if path not in gn_loaded: | |
| return path | |
| return None | |
| def check_unused(entry: Entry) -> tuple[Status, str] | None: | |
| """Keeps entries whose packages may hide consumers from the graphs.""" | |
| if entry.kind not in (Kind.PACKAGE, Kind.SUBPACKAGES, Kind.TARGET): | |
| return None | |
| if not entry.package: | |
| return None | |
| if tools.git_is_untracked_and_ignored(entry.package): | |
| # E.g. //vendor/...: another repository, or not checked out. | |
| return Status.UNKNOWN, ( | |
| f"//{entry.package} is not part of fuchsia.git, so it was " | |
| "not searched" | |
| ) | |
| if not os.path.isdir(os.path.join(fuchsia_dir, entry.package)): | |
| return None | |
| if gn_loaded is None: | |
| return None | |
| unloaded = first_unloaded_build_gn(entry) | |
| if unloaded is None: | |
| return None | |
| return Status.UNVERIFIED, ( | |
| f"{unloaded} is not loaded by the current GN build" | |
| ) | |
| # Describe the target. | |
| print(f"\nTarget: {target}") | |
| if bazel.info is not None: | |
| print(f" Bazel: {bazel.info.kind}") | |
| elif not args.gn_only: | |
| print(" Bazel: no target") | |
| if gn.visibility is not None: | |
| how = "generated by bazel2gn" if gn_generated else "hand-written" | |
| print(f" GN: {how}, in {len(gn.instances)} toolchain(s)") | |
| elif not args.bazel_only: | |
| print(" GN: no target in the current build graph") | |
| if not args.gn_only: | |
| errors = ( | |
| f", ignored {bazel.query_errors} errors from packages that failed " | |
| "to load (--keep_going; use --verbose to see the commands)" | |
| if bazel.query_errors | |
| else "" | |
| ) | |
| print( | |
| f" Bazel consumers: {len(bazel.consumers)} " | |
| f"(rdeps in {args.bazel_universe}{errors})" | |
| ) | |
| if not args.bazel_only: | |
| print( | |
| f" GN consumers: {len(gn.consumers)} " | |
| f"(gn refs in {os.path.relpath(tools.build_dir, fuchsia_dir)})" | |
| ) | |
| # The Bazel list, as written by the user. | |
| bazel_entries: list[str] | None = None | |
| bazel_implicit: list[Entry] = [Entry(Kind.PACKAGE, own)] | |
| if bazel.info is not None: | |
| effective = bazel.info.visibility | |
| if effective is None or (source is not None and not source.present): | |
| print( | |
| "\nThe Bazel target does not set `visibility`; it uses the " | |
| "package's default_visibility. Nothing to trim." | |
| ) | |
| elif source is not None and source.entries is not None: | |
| bazel_entries = source.entries | |
| written = {parse_bazel_entry(t, own) for t in bazel_entries} | |
| for text in effective: | |
| entry = parse_bazel_entry(text, own) | |
| if entry not in written and entry not in bazel_implicit: | |
| bazel_implicit.append(entry) | |
| else: | |
| print( | |
| "\nWARNING: could not statically resolve `visibility` in " | |
| "BUILD.bazel; using the effective value from `bazel query`, " | |
| "which may include entries added by macros." | |
| ) | |
| bazel_entries = effective | |
| added = [ | |
| text | |
| for text in (entry_to_bazel(e, own) for e in bazel_implicit[1:]) | |
| if text is not None | |
| ] | |
| if added: | |
| print( | |
| "\nNote: entries added by a macro, ignored: " | |
| + ", ".join(f'"{a}"' for a in added) | |
| ) | |
| overwrites = source.overwrites if source is not None else [] | |
| split = False | |
| if bazel_entries is not None and gn.visibility is not None: | |
| if not gn_generated: | |
| split = True | |
| print( | |
| "\nNote: the GN target is hand-written, so the Bazel and GN " | |
| "lists are trimmed separately." | |
| ) | |
| elif overwrites: | |
| split = True | |
| print( | |
| "\nNote: `visibility` has bazel2gn overwrite annotations (" | |
| + "; ".join(overwrites) | |
| + "), so the Bazel list is checked against Bazel consumers " | |
| "only, and the GN list against GN consumers only." | |
| ) | |
| else: | |
| expected = expected_gn_entries( | |
| [parse_bazel_entry(t, own) for t in bazel_entries], own | |
| ) | |
| actual = {parse_gn_entry(t, own) for t in gn.visibility} | |
| if expected != actual | {Entry(Kind.PACKAGE, own)}: | |
| split = True | |
| print( | |
| "\nWARNING: the GN visibility differs from the Bazel one " | |
| "and there is no bazel2gn overwrite annotation. Is the " | |
| "BUILD.gn stale (run `fx bazel2gn`)? Trimming the lists " | |
| "separately." | |
| ) | |
| if bazel_entries is not None: | |
| bazel_consumers_only = split or gn.visibility is None | |
| if bazel_consumers_only: | |
| consumers = bazel.consumers | |
| title = "BUILD.bazel visibility (checked against Bazel consumers)" | |
| else: | |
| consumers = bazel.consumers | gn.consumers | |
| title = ( | |
| "BUILD.bazel visibility (checked against Bazel and GN " | |
| "consumers, as bazel2gn derives the GN list from it)" | |
| ) | |
| def check_unused_bazel(entry: Entry) -> tuple[Status, str] | None: | |
| result = check_unused(entry) | |
| # Unloaded BUILD.gn files only matter for GN consumers. | |
| if ( | |
| bazel_consumers_only | |
| and result | |
| and result[0] == Status.UNVERIFIED | |
| ): | |
| return None | |
| return result | |
| report = analyze_list( | |
| bazel_entries, | |
| parse_bazel_entry, | |
| own, | |
| consumers, | |
| implicit_entries=bazel_implicit, | |
| groups=bazel.groups, | |
| reference_files=[ | |
| f | |
| for f in reference_files | |
| if not bazel_consumers_only or not is_gn_file(f) | |
| ], | |
| check_unused=check_unused_bazel, | |
| macro_locations=bazel.macro_locations, | |
| ) | |
| print_list_report(title, report, graphs, gn_loaded, args.verbose) | |
| kept = [e.text for e in report.kept()] | |
| print("\nTrimmed list for BUILD.bazel:\n") | |
| print(format_bazel_list(kept)) | |
| if gn.visibility is not None and (split or bazel_entries is None): | |
| gn_list = gn.visibility | |
| report = analyze_list( | |
| gn_list, | |
| parse_gn_entry, | |
| own, | |
| gn.consumers, | |
| reference_files=[f for f in reference_files if is_gn_file(f)], | |
| check_unused=check_unused, | |
| ) | |
| print_list_report( | |
| "GN visibility, from `gn desc` (checked against GN consumers)", | |
| report, | |
| graphs, | |
| gn_loaded, | |
| args.verbose, | |
| ) | |
| kept_entries = report.kept() | |
| kept_gn = [entry_to_gn(e.entry, own) for e in kept_entries] | |
| if split and gn_generated: | |
| print("\nTrimmed GN list, for the bazel2gn annotation:\n") | |
| print(format_bazel2gn_overwrite(kept_gn)) | |
| else: | |
| print("\nTrimmed list for BUILD.gn:\n") | |
| print(format_gn_list(kept_gn)) | |
| if bazel.info is None: | |
| print( | |
| "\nBazel equivalent, for when the target is migrated " | |
| "(single-target entries are widened to their package):\n" | |
| ) | |
| print( | |
| format_bazel_list( | |
| gn_list_to_bazel([e.entry for e in kept_entries], own) | |
| ) | |
| ) | |
| return 0 | |
| if __name__ == "__main__": | |
| try: | |
| sys.exit(main()) | |
| except Error as e: | |
| print(f"ERROR: {e}", file=sys.stderr) | |
| sys.exit(1) |