| # 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) |