blob: ad4e2528a4501879edd2abcd6be5bc04af4f8a72 [file]
#!/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)