blob: c90d1c9444cea905fbb7fcc4ec25064497fc6fa8 [file]
# 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.
"""Defines SHAC checks for Bazel files."""
load("./common.star", "cipd_platform_name", "get_fuchsia_dir", "os_exec")
def _bazel_default_applicable_licenses(ctx):
"""Checks that non-third-party BUILD.bazel files have default_applicable_licenses set."""
allowlist_path = "build/bazel/shac/allowlists.json"
data = json.decode(str(ctx.io.read_file(allowlist_path)))
ignored_prefixes = data.get("ignored_prefixes", [])
ignored_files = data.get("ignored_files", [])
for f in ctx.scm.affected_files(glob = ["BUILD.bazel"]):
should_ignore = False
if f in ignored_files:
should_ignore = True
else:
for p in ignored_prefixes:
prefix = p if p.endswith("/") else p + "/"
if f.startswith(prefix):
should_ignore = True
break
if should_ignore:
continue
contents = str(ctx.io.read_file(f))
# Match package( ... default_applicable_licenses = ["//:license"] ... )
# Using [^)]* to match anything except closing parenthesis to stay within the package call.
if not ctx.re.allmatches(r"package\([^)#]*default_applicable_licenses\s*=\s*\[\"//:license\"\][^)]*\)", contents):
ctx.emit.finding(
level = "error",
message = "BUILD.bazel files must include `default_applicable_licenses = [\"//:license\"]` within a `package()` call to set the default file-level license.",
filepath = f,
)
# TODO(https://fxbug.dev/565897544): Move `_EXCLUDED_GLOBS` and
# `_make_bazel_lint_check` to a `.star` file in `bazel_lint/` in a follow-up CL.
_EXCLUDED_GLOBS = [
"!/third_party/**/src/**",
# Generated by `fx update-bazel-vendor-dir` from upstream content.
"!/third_party/bazel_vendor/**",
]
# File globs for Bazel lint checks.
#
# When a check is configured with one of these glob lists, the corresponding
# BazelCheck subclass in Python must declare the matching `FileKind`(s) in its
# `file_kinds` set. The framework will fail if a file matching these globs is
# passed to a check that does not declare its `FileKind`.
#
# LINT.IfChange(file_globs)
# Matches BUILD files for defining targets (handled by FileKind.BUILD).
_BUILD_FILES = [
"BUILD.bazel",
"*.BUILD.bazel",
]
# Matches .bzl files (handled by FileKind.BZL).
_BZL_FILES = [
"*.bzl",
]
# Matches Python files (handled by FileKind.PYTHON).
# buildifier: disable=unused-variable
_PYTHON_FILES = [
"*.py",
]
# Matches BUILD and .bzl files (handled by FileKind.BUILD and FileKind.BZL).
# buildifier: disable=unused-variable
_BUILD_AND_BZL_FILES = _BUILD_FILES + _BZL_FILES
# Matches all Bazel files (handled by FileKind.BUILD, FileKind.BZL,
# FileKind.WORKSPACE, and FileKind.MODULE).
_ALL_BAZEL_FILES = [
"*.bazel",
"*.bzl",
]
# LINT.ThenChange(//scripts/shac/bazel_lint/framework.py:file_kinds)
_ENFORCEMENT_ALL = "all"
_ENFORCEMENT_UNCHANGED_LINES_WARNING_ONLY = "unchanged_lines_warning_only"
_VALID_ENFORCEMENT_MODES = (
_ENFORCEMENT_ALL,
_ENFORCEMENT_UNCHANGED_LINES_WARNING_ONLY,
)
def _make_bazel_lint_check(check_name, *, applies_to_globs, enforcement = _ENFORCEMENT_ALL):
"""Creates a shac check that runs a Python AST Bazel lint rule.
Args:
check_name: Name of the shac check.
applies_to_globs: List of file globs to which this check applies.
enforcement: Controls how findings on unchanged lines and untouched files
are handled:
- "all" (default): Enforces the check across whole affected files,
reporting all findings at the level specified by the check.
- "unchanged_lines_warning_only": Reports findings on added or modified
lines (and file-level findings on modified files) at the level
specified by the check, while downgrading "error" findings on
unchanged lines (and in untouched files when `shac check --all` is
run) to "warning".
This is primarily intended for rolling out new checks to avoid errors
in CQ/CI caused by race conditions between check CLs and CLs that
violate the check but have already passed CQ. It should be temporary
unless there is a very specific reason, in which case that reason
should be noted above the use.
Findings are downgraded rather than dropped so that pre-existing
violations stay visible.
Upstream third-party source files (!/third_party/**/src/**) and vendored
upstream Bazel repositories (!/third_party/bazel_vendor/**) are excluded
because upstream projects manage their own package structures and should not
be subject to Fuchsia workspace conventions. This exception may need to be
relaxed if we have to write BUILD.bazel files for third-party repositories.
"""
if enforcement not in _VALID_ENFORCEMENT_MODES:
fail("Unsupported enforcement mode: %s" % enforcement)
def _impl(ctx):
# Temporarily exclude repositories other than `fuchsia.git` to avoid
# breaking unknown use cases.
# TODO(https://fxbug.dev/565897544): Remove this.
if ctx.scm.root != get_fuchsia_dir(ctx):
return
affected = ctx.scm.affected_files(glob = applies_to_globs + _EXCLUDED_GLOBS)
files = list(affected.keys())
if not files:
return
fuchsia_dir = get_fuchsia_dir(ctx)
python_bin = "%s/prebuilt/third_party/python3/%s/bin/python3" % (
fuchsia_dir,
cipd_platform_name(ctx),
)
checker_script = "%s/scripts/shac/bazel_lint/main.py" % fuchsia_dir
# Exit code 0 means no findings; exit code 42 means lint findings were
# reported to stdout. Any other exit code (such as 1 from an uncaught
# Python exception or 2 from a CLI/runtime error) is treated by os_exec
# as a fatal failure before attempting to decode stdout.
# LINT.IfChange(exit_codes)
ok_retcodes = (0, 42)
# LINT.ThenChange(//scripts/shac/bazel_lint/main.py:exit_codes)
res = os_exec(
ctx,
[
python_bin,
checker_script,
"--check",
check_name,
"--root",
ctx.scm.root,
"--json",
] + files,
ok_retcodes = ok_retcodes,
).wait()
findings = json.decode(res.stdout)
if type(findings) != "list":
fail("Expected a list of findings from %s (exit code %d), got: %s\nstderr: %s" % (
checker_script,
res.retcode,
res.stdout,
res.stderr,
))
if bool(res.retcode) != bool(findings):
fail("%s exited with code %d, but reported %d findings" % (
checker_script,
res.retcode,
len(findings),
))
if not findings:
return
# Cache of changed line numbers per file to avoid recalculating for
# multiple findings in the same file. Populated on demand as values
# are needed.
changed_lines_by_file = {}
for finding in findings:
filepath = finding["filepath"]
line = finding.get("line")
end_line = finding.get("end_line")
level = finding["level"]
# In "unchanged_lines_warning_only" enforcement mode, downgrade
# "error" findings whose [line, end_line] span does not overlap
# any added or modified line in the file to "warning". File-level
# findings without a line number (`line == None`) apply at the
# check's level to any file modified in git (`meta.action != ""`,
# including files modified only by deletions), and are downgraded
# to "warning" on untouched files.
if enforcement == _ENFORCEMENT_UNCHANGED_LINES_WARNING_ONLY and level == "error":
if line:
if filepath not in changed_lines_by_file:
changed_lines_by_file[filepath] = set([
num
for num, _ in affected[filepath].affected_lines(changed_only = True)
])
file_changed_lines = changed_lines_by_file[filepath]
span_end = end_line if end_line else line
touches_changed_line = any([
ln in file_changed_lines
for ln in range(line, span_end + 1)
])
else:
# File-level finding (no line number).
touches_changed_line = bool(affected[filepath].action)
if not touches_changed_line:
level = "warning"
kwargs = {
"level": level,
"filepath": filepath,
}
if line:
kwargs["line"] = line
if end_line:
kwargs["end_line"] = end_line
if finding.get("col"):
kwargs["col"] = finding["col"]
if finding.get("end_col"):
kwargs["end_col"] = finding["end_col"]
ctx.emit.finding(
message = finding["message"],
**kwargs
)
return shac.check(_impl, name = check_name, formatter = False)
def _bazel_no_repo_rules_canonical_names(ctx):
"""Rejects hardcoded canonical names of `use_repo_rule()` repositories.
Bzlmod names each `use_repo_rule()` repository after the declaration's
position in its `MODULE.bazel` (`+_repo_rules<N>+<name>`), so adding or
moving a declaration silently renames every one after it. See
https://fxbug.dev/566262167. Note: this assumption may change when we roll a
new Bazel version.
"""
pattern = r"_repo_rules\d*\+"
# Canonical names get hardcoded where code has to name a repository it
# can't see by its apparent name: Starlark in another module's repo
# mapping, or Python that builds labels or parses Bazel query output.
# Other file types are left alone so that e.g. docs explaining the naming
# scheme aren't flagged.
files = ctx.scm.affected_files(glob = ["*.bazel", "*.bzl", "*.py"])
for path, meta in files.items():
for num, line in meta.affected_lines():
for match in ctx.re.allmatches(pattern, line):
ctx.emit.finding(
level = "error",
message = (
"Don't hardcode the canonical name of a " +
"`use_repo_rule()` repository; it encodes the " +
"declaration's position in MODULE.bazel and changes " +
"whenever a `use_repo_rule()` is added or moved " +
"above it. Refer to the repository by its apparent " +
"name instead (e.g. `@internal_sdk`), passing it " +
"through a label-typed attribute or `inject_repo()` " +
"if it isn't visible where it's used."
),
filepath = path,
line = num,
col = match.offset + 1,
end_col = match.offset + 1 + len(match.groups[0]),
)
_GLOB_ALLOWLIST_PATH = "build/bazel/shac/glob_allowlist.json"
_RECURSIVE_GLOB_ALLOWLIST_PATH = "build/bazel/shac/recursive_glob_allowlist.json"
def _code(line):
# Mentions in comments are fine.
return line.split("#", 1)[0]
def _glob_calls(ctx, line):
return ctx.re.allmatches(r"\bglob\s*\(", _code(line))
def _recursive_glob_patterns(ctx, line):
# Patterns are often built up in variables or passed through macros, so
# look for any string literal containing "**" rather than only literals
# inside glob() calls. glob() only accepts "**" as a whole path segment,
# so requiring it to sit between quotes or slashes avoids matching kwargs
# unpacking or Markdown bold text in docs.
return ctx.re.allmatches(
r"(\"(?:[^\"]*/)?\*\*(?:/[^\"]*)?\"|'(?:[^']*/)?\*\*(?:/[^']*)?')",
_code(line),
)
def _check_allowlisted_matches(ctx, allowlist_path, find_func, message, description):
"""Reports matches of `find_func` in Bazel files that aren't allowlisted.
The allowlist at `allowlist_path` is a JSON object whose "allowed_paths"
key lists files, or directories if the entry ends in "/", that may contain
matches. The allowlist is meant to only shrink, so entries that no longer
cover any match are errors too. Upstream third-party code is excluded.
Args:
ctx: A ctx instance.
allowlist_path: Path to the allowlist, relative to the shac root.
find_func: Function taking (ctx, line) and returning ctx.re.allmatches()
results for the disallowed pattern.
message: Message for findings in Bazel files.
description: Short description of a match, for stale allowlist
findings.
"""
allowlist_contents = str(ctx.io.read_file(allowlist_path))
allowlist = json.decode(allowlist_contents)["allowed_paths"]
allowed_files = set([e for e in allowlist if not e.endswith("/")])
allowed_dirs = [e for e in allowlist if e.endswith("/")]
def is_allowed(path):
if path in allowed_files:
return True
return any([path.startswith(d) for d in allowed_dirs])
def has_match(path):
return any([
find_func(ctx, line)
for line in str(ctx.io.read_file(path)).splitlines()
])
files = ctx.scm.affected_files(glob = [
"*.bazel",
"*.bzl",
"!**/third_party/**",
])
for path, meta in files.items():
if is_allowed(path):
continue
for num, line in meta.affected_lines():
for match in find_func(ctx, line):
ctx.emit.finding(
level = "error",
message = message,
filepath = path,
line = num,
col = match.offset + 1,
end_col = match.offset + 1 + len(match.groups[0]),
)
# Every entry is checked on every run, not just affected files, so a change
# that removes the last match an entry covers fails until it also trims
# the allowlist. The allowlisted files are few and small, so reading them
# is cheap.
all_files = set(ctx.scm.all_files())
allowlist_lines = allowlist_contents.splitlines()
for entry in allowlist:
if entry.endswith("/"):
if any([
has_match(f)
for f in all_files
if f.startswith(entry) and (f.endswith(".bazel") or f.endswith(".bzl"))
]):
continue
reason = "contains no Bazel files that use %s" % description
elif entry not in all_files:
reason = "doesn't exist"
elif has_match(entry):
continue
else:
reason = "no longer uses %s" % description
line = [
i + 1
for i, text in enumerate(allowlist_lines)
if json.encode(entry) in text
][0]
ctx.emit.finding(
level = "error",
message = "%s %s; remove it from the allowlist." % (entry, reason),
filepath = allowlist_path,
line = line,
)
def _bazel_no_glob(ctx):
"""Rejects uses of `glob()` in Bazel files.
`fx build` builds Bazel targets through Ninja actions, and those actions'
depfiles only list the files that a `glob()` matched when it last ran.
Adding a file that the `glob()` would now match changes nothing Ninja
tracks, so incremental builds keep using the old file list until something
else invalidates the action. Listing files explicitly avoids this, because
adding one means editing a BUILD.bazel file, which Ninja does track.
This is only a problem because Ninja drives Bazel. Once the GN-to-Bazel
migration is done and Bazel builds directly, Bazel's own tracking of glob
results makes `glob()` safe again, so this check could be reconsidered.
Even then, bazel_no_recursive_glob keeps enforcing the Bazel style guide's
ban on recursive globs.
Files that already used `glob()` when this check was added are listed in
build/bazel/shac/glob_allowlist.json. Entries ending in "/" allow every
file under that directory, for directories like sdk/history where new
files that use `glob()` show up regularly.
"""
_check_allowlisted_matches(
ctx,
_GLOB_ALLOWLIST_PATH,
_glob_calls,
message = (
"Don't use `glob()`; list files explicitly instead. " +
"Ninja doesn't notice when a file that a `glob()` " +
"would match is added, so incremental `fx build` " +
"and `fx test` runs can silently use a stale file " +
"list."
),
description = "`glob()`",
)
def _bazel_no_recursive_glob(ctx):
"""Rejects recursive glob patterns (containing `**`) in Bazel files.
The Bazel style guide forbids recursive globs for source files
(https://bazel.build/build/style-guide#recursive): they skip
subdirectories that have their own BUILD file, which makes it hard to tell
what they match, and they cache and parallelize worse than a BUILD file per
directory. Fuchsia's Bazel style guide extends this to all files. Unlike
bazel_no_glob, this applies regardless of whether Ninja drives Bazel.
Existing uses, mostly templates for external repositories and generated
files, are listed in build/bazel/shac/recursive_glob_allowlist.json.
Files are scanned line by line, so patterns in docstrings and other
multi-line strings count too.
"""
_check_allowlisted_matches(
ctx,
_RECURSIVE_GLOB_ALLOWLIST_PATH,
_recursive_glob_patterns,
message = (
"Don't use recursive glob patterns (`**`). List files " +
"explicitly, or add a BUILD.bazel file to each subdirectory. " +
"See https://bazel.build/build/style-guide#recursive."
),
description = "recursive glob patterns",
)
# Reports an error if a disallowed workspace root package label is used.
# Workspace root package labels (e.g. `//:...`) should only refer to targets
# or files defined directly in the root BUILD.bazel file.
_bazel_disallowed_workspace_root_package_labels = _make_bazel_lint_check(
"bazel_disallowed_workspace_root_package_labels",
applies_to_globs = _ALL_BAZEL_FILES,
)
# Reports an error when visibility("public") is used and a warning when
# visibility("//...") is used in .bzl files.
# TODO(https://fxbug.dev/570203009): Switch to "all" once rolled out.
_bazel_load_visibility = _make_bazel_lint_check(
"bazel_load_visibility",
applies_to_globs = _BZL_FILES,
enforcement = "unchanged_lines_warning_only",
)
# Reports an error when default_visibility in package() uses broad visibility
# and a warning when it uses narrower visibility.
# TODO(https://fxbug.dev/570203009): Switch to "all" once rolled out.
_bazel_package_default_visibility = _make_bazel_lint_check(
"bazel_package_default_visibility",
applies_to_globs = _BUILD_FILES,
enforcement = "unchanged_lines_warning_only",
)
# Reports an error when "//visibility:public" is used on targets outside
# exempted directories.
# TODO(https://fxbug.dev/570203009): Switch to "all" once rolled out.
_bazel_target_visibility = _make_bazel_lint_check(
"bazel_target_visibility",
applies_to_globs = _BUILD_AND_BZL_FILES,
enforcement = "unchanged_lines_warning_only",
)
def register_bazel_checks():
"""Registers all Bazel shac checks."""
shac.register_check(shac.check(_bazel_default_applicable_licenses, formatter = False))
shac.register_check(shac.check(_bazel_no_glob, formatter = False))
shac.register_check(shac.check(_bazel_no_recursive_glob, formatter = False))
shac.register_check(shac.check(_bazel_no_repo_rules_canonical_names, formatter = False))
# Ensure tests are added and the filegroups used by tests are updated along
# with checks.
# LINT.IfChange(register_all_checks)
shac.register_check(_bazel_disallowed_workspace_root_package_labels)
shac.register_check(_bazel_load_visibility)
shac.register_check(_bazel_package_default_visibility)
shac.register_check(_bazel_target_visibility)
# LINT.ThenChange(//scripts/shac/bazel_lint/checks/__init__.py:all_checks)