blob: 33d4c2aa98d3532d62ff3fcf5fbb13c7ee9442e4 [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.
"""Unit tests for the bazel_lint framework."""
import ast
import io
import json
import pathlib
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
# Add scripts/shac/bazel_lint so modules can be imported directly when run
# outside GN's python_host_test archive.
sys.path.insert(
0,
str(pathlib.Path(__file__).resolve().parent.parent / "bazel_lint"),
)
import allowlist as allowlist_mod
import framework
import main
class SimpleCheckForTests(framework.BazelCheck):
"""Simple BazelCheck implementation for unit testing the framework."""
name = "simple_check_for_tests"
file_kinds = frozenset(
{
framework.FileKind.BUILD,
framework.FileKind.BZL,
framework.FileKind.MODULE,
framework.FileKind.WORKSPACE,
}
)
policy_exempt_globs = ("**/some_other_repo/**", "tools/**")
def check_string(
self, fc: framework.FileContext, s: framework.StringInfo
) -> None:
if s.is_docstring:
return
if s.value.startswith("//:bad/"):
fc.report_string(
f"Disallowed label: {s.value}",
s.node,
s.value,
level="error",
url=None,
)
class TestAllowlist(unittest.TestCase):
"""Tests for bazel_lint.allowlist."""
def test_missing_allowlist_file_returns_empty(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
al = allowlist_mod.load_allowlist(
Path(tmpdir) / "nonexistent_check.json",
supports_targets_allowlist=False,
)
self.assertEqual(al.entries, ())
self.assertFalse(al.is_file_allowlisted("src/BUILD.bazel"))
def test_valid_allowlist_matching(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
al_path = Path(tmpdir) / "my_check.json"
al_path.write_text(
json.dumps(
{
"entries": [
{
"bug": "https://fxbug.dev/570203009",
"comment": "Subtree exemption",
"path_prefix": "vendor/google/",
},
{
"bug": "https://fxbug.dev/570203009",
"path": "src/bar/BUILD.bazel",
"targets": ["allowed_target"],
},
{
"bug": "https://fxbug.dev/570203009",
"path": "src/whole_file/BUILD.bazel",
},
]
}
)
)
al = allowlist_mod.load_allowlist(
al_path, supports_targets_allowlist=True
)
# path_prefix without targets exempts the whole subtree.
self.assertTrue(
al.is_file_allowlisted("vendor/google/bar/BUILD.bazel")
)
self.assertFalse(al.is_file_allowlisted("vendor/other/BUILD.bazel"))
# path without targets exempts the whole file.
self.assertTrue(
al.is_file_allowlisted("src/whole_file/BUILD.bazel")
)
# path with targets does NOT exempt the whole file, only specific targets.
self.assertFalse(al.is_file_allowlisted("src/bar/BUILD.bazel"))
self.assertTrue(
al.is_finding_allowlisted(
"src/bar/BUILD.bazel", ("allowed_target",)
)
)
self.assertFalse(
al.is_finding_allowlisted(
"src/bar/BUILD.bazel", ("other_target",)
)
)
self.assertFalse(
al.is_finding_allowlisted("src/bar/BUILD.bazel", ())
)
def test_invalid_allowlist_schemas(self) -> None:
cases = [
(
"missing bug",
{"entries": [{"path": "a/BUILD.bazel"}]},
False,
"'bug' must be a string matching",
),
(
"bad bug url",
{
"entries": [
{
"bug": "http://example.com/123",
"path": "a/BUILD.bazel",
}
]
},
False,
"'bug' must be a string matching",
),
(
"unknown key",
{
"entries": [
{
"bug": "https://fxbug.dev/123",
"path": "a/BUILD.bazel",
"explanation": "wrong key name",
}
]
},
False,
"unknown keys",
),
(
"path_prefix without trailing slash",
{
"entries": [
{
"bug": "https://fxbug.dev/123",
"path_prefix": "src/foo",
}
]
},
False,
"must be a non-empty directory prefix ending with '/'",
),
(
"targets on unsupported check",
{
"entries": [
{
"bug": "https://fxbug.dev/123",
"path": "a/BUILD.bazel",
"targets": ["foo"],
}
]
},
False,
"'targets' is not supported for this check",
),
(
"no scope specified",
{"entries": [{"bug": "https://fxbug.dev/123"}]},
False,
"must specify exactly one of 'path' or 'path_prefix'",
),
(
"disallowed vendor path",
{
"entries": [
{
"bug": "https://fxbug.dev/123",
"path": "vendor/google/foo/BUILD.bazel",
}
]
},
False,
"starting with 'vendor' must be one of",
),
(
"disallowed vendor path_prefix",
{
"entries": [
{
"bug": "https://fxbug.dev/123",
"path_prefix": "vendor/google/foo/",
}
]
},
False,
"starting with 'vendor' must be one of",
),
]
for name, payload, supports_targets, expected_err in cases:
with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp:
al_path = Path(tmp) / "test_check.json"
al_path.write_text(json.dumps(payload))
with self.assertRaisesRegex(
allowlist_mod.AllowlistError, expected_err
):
allowlist_mod.load_allowlist(
al_path,
supports_targets_allowlist=supports_targets,
)
class TestFramework(unittest.TestCase):
"""Tests for bazel_lint.framework and bazel_lint.main."""
def test_classify_file(self) -> None:
self.assertEqual(
framework.classify_file("foo/BUILD.bazel"),
framework.FileKind.BUILD,
)
self.assertEqual(
framework.classify_file("foo/bar.BUILD.bazel"),
framework.FileKind.BUILD,
)
self.assertEqual(
framework.classify_file("foo/BUILD"),
framework.FileKind.BUILD,
)
self.assertEqual(
framework.classify_file("foo/bar.BUILD"),
framework.FileKind.BUILD,
)
self.assertEqual(
framework.classify_file("foo/defs.bzl"),
framework.FileKind.BZL,
)
self.assertEqual(
framework.classify_file("MODULE.bazel"),
framework.FileKind.MODULE,
)
self.assertEqual(
framework.classify_file("foo.MODULE.bazel"),
framework.FileKind.MODULE,
)
self.assertEqual(
framework.classify_file("WORKSPACE.bazel"),
framework.FileKind.WORKSPACE,
)
self.assertEqual(
framework.classify_file("WORKSPACE"),
framework.FileKind.WORKSPACE,
)
self.assertEqual(
framework.classify_file("WORKSPACE.bzlmod"),
framework.FileKind.WORKSPACE,
)
self.assertEqual(
framework.classify_file("foo.WORKSPACE.bazel"),
framework.FileKind.WORKSPACE,
)
self.assertEqual(
framework.classify_file("foo/script.py"),
framework.FileKind.PYTHON,
)
for invalid_path in (
"README.md",
"foo/BUILD.gn",
"foo.bzlmod",
"foo.WORKSPACE",
"foo.workspace",
):
with self.subTest(invalid_path=invalid_path):
with self.assertRaises(ValueError):
framework.classify_file(invalid_path)
def test_file_kind_mismatch_raises_value_error(self) -> None:
check = SimpleCheckForTests()
with tempfile.TemporaryDirectory() as tmpdir:
py_file = Path(tmpdir) / "foo.py"
py_file.write_text("x = 1\n")
with self.assertRaisesRegex(
ValueError, "does not accept FileKind.PYTHON"
):
main._check_file(
py_file, check=check, root=None, allowlist=None
)
def test_is_path_exempt_by_policy(self) -> None:
check = SimpleCheckForTests()
self.assertTrue(
check.is_path_exempt_by_policy("some_other_repo/foo/BUILD.bazel")
)
self.assertTrue(
check.is_path_exempt_by_policy(
"vendor/some_other_repo/foo/BUILD.bazel"
)
)
self.assertFalse(
check.is_path_exempt_by_policy(
"vendor/my_some_other_repo/foo/BUILD.bazel"
)
)
self.assertTrue(check.is_path_exempt_by_policy("tools/foo/BUILD.bazel"))
def test_register_check_targets_without_supports_allowlist_raises(
self,
) -> None:
class InvalidTargetsWithoutAllowlist(framework.BazelCheck):
name = "invalid_targets_without_allowlist"
file_kinds = frozenset({framework.FileKind.BUILD})
supports_allowlist = False
supports_targets_allowlist = True
with self.assertRaisesRegex(
ValueError,
"'supports_targets_allowlist = True' requires 'supports_allowlist = True'",
):
framework.register_check(InvalidTargetsWithoutAllowlist)
def test_supports_allowlist_false_rejects_allowlist_and_skips_loading(
self,
) -> None:
check = SimpleCheckForTests()
self.assertFalse(check.supports_allowlist)
# `run_check_on_contents` fails if `supports_allowlist` is False and an
# allowlist instance is passed.
with self.assertRaisesRegex(
ValueError,
"does not support allowlists \\(supports_allowlist = False\\)",
):
framework.run_check_on_contents(
check,
'x = "//foo:bar"\n',
"src/BUILD.bazel",
allowlist=allowlist_mod.Allowlist(),
)
with mock.patch("importlib.import_module"), mock.patch.dict(
framework.REGISTRY,
{"simple_check_for_tests": check},
clear=True,
), tempfile.TemporaryDirectory() as tmpdir:
build_file = Path(tmpdir) / "BUILD.bazel"
build_file.write_text('tool = "//foo:bar"\n')
allowlists_dir = Path(tmpdir) / "allowlists"
allowlists_dir.mkdir()
# When no allowlist file exists on disk, `main.main` does not call
# `load_allowlist` and passes `allowlist=None`.
with mock.patch.object(
main, "load_allowlist"
) as mock_load_al, io.StringIO() as mock_stdout, mock.patch.object(
sys, "stdout", mock_stdout
):
ret = main.main(
[
"--check",
"simple_check_for_tests",
"--root",
str(tmpdir),
"--allowlists-dir",
str(allowlists_dir),
"--json",
str(build_file),
]
)
self.assertEqual(ret, main.EXIT_OK)
mock_load_al.assert_not_called()
# When an allowlist file exists on disk for a check with
# `supports_allowlist = False`, `main.main` fails with EXIT_ERROR.
(allowlists_dir / "simple_check_for_tests.json").write_text(
json.dumps({"entries": []})
)
with io.StringIO() as mock_stderr, mock.patch.object(
sys, "stderr", mock_stderr
):
ret = main.main(
[
"--check",
"simple_check_for_tests",
"--root",
str(tmpdir),
"--allowlists-dir",
str(allowlists_dir),
"--json",
str(build_file),
]
)
self.assertEqual(ret, main.EXIT_ERROR)
self.assertIn(
"allowlist file is not permitted for check 'simple_check_for_tests'",
mock_stderr.getvalue(),
)
def test_register_check_python_overriding_check_load_raises(self) -> None:
class InvalidPythonCheckLoad(framework.BazelCheck):
name = "invalid_python_check_load"
file_kinds = frozenset({framework.FileKind.PYTHON})
def check_load(
self, fc: framework.FileContext, load: framework.LoadInfo
) -> None:
pass
with self.assertRaisesRegex(
TypeError,
"checks accepting FileKind.PYTHON cannot override check_load",
):
framework.register_check(InvalidPythonCheckLoad)
def test_register_check_python_check_module_allowed(self) -> None:
class ValidPythonCheckModule(framework.BazelCheck):
name = "valid_python_check_module"
file_kinds = frozenset({framework.FileKind.PYTHON})
def check_module(self, fc: framework.FileContext) -> None:
if not fc.lines:
fc.report("Empty file", level="warning", url=None)
framework.register_check(ValidPythonCheckModule)
self.assertIn("valid_python_check_module", framework.REGISTRY)
del framework.REGISTRY["valid_python_check_module"]
def test_docstring_and_comment_handling(self) -> None:
content = '''
# Full-line comment with "//:bad/comment"
"""Module docstring //:bad/module."""
class MyClass:
"""Class docstring //:bad/class."""
def my_func():
"""Function docstring //:bad/func."""
return "//:bad/code" # Inline comment with "//:bad/inline"
'''
class AllStringsCheck(framework.BazelCheck):
name = "all_strings_check"
file_kinds = frozenset({framework.FileKind.PYTHON})
def check_string(
self, fc: framework.FileContext, s: framework.StringInfo
) -> None:
fc.report_string(
f"Found {s.value} (is_docstring={s.is_docstring})",
s.node,
level="error",
url=None,
)
class SkipDocstringsCheck(framework.BazelCheck):
name = "skip_docstrings_check"
file_kinds = frozenset({framework.FileKind.PYTHON})
def check_string(
self, fc: framework.FileContext, s: framework.StringInfo
) -> None:
if s.is_docstring:
return
fc.report_string(
f"Found {s.value}", s.node, level="error", url=None
)
# Comments are stripped by the AST and never passed to check_string;
# docstrings are included with is_docstring=True.
all_findings = framework.run_check_on_contents(
AllStringsCheck(), content, "foo/script.py"
)
self.assertEqual(
[f["message"] for f in all_findings],
[
"Found Module docstring //:bad/module. (is_docstring=True)",
"Found Class docstring //:bad/class. (is_docstring=True)",
"Found Function docstring //:bad/func. (is_docstring=True)",
"Found //:bad/code (is_docstring=False)",
],
)
non_docstring_findings = framework.run_check_on_contents(
SkipDocstringsCheck(), content, "foo/script.py"
)
self.assertEqual(
[f["message"] for f in non_docstring_findings],
["Found //:bad/code"],
)
def test_file_context_extraction_and_spans(self) -> None:
content = """
load("//build:defs.bzl", "my_macro", _alias = "other")
visibility("private")
TOP_VAR = "//:bad/top_var"
ASSIGNED_SELECT = select({
"//conditions:default": ["//:bad/in_assigned_select"],
})
ASSIGNED_NAMED = helper_with_name(
name = "assigned_helper_name",
src = "//:bad/in_assigned_named_call",
)
my_macro(
name = "target_a",
srcs = ["//:bad/direct_arg"]
+ select({
"//conditions:default": ["//:bad/in_select"],
})
+ glob(["//:bad/in_glob"])
+ select({
"//conditions:default": glob(["//:bad/in_select_glob"]),
}),
data = compute_data("//:bad/in_value_fn")
+ outer_helper(
inner_kw = select({
"//conditions:default": ["//:bad/in_intermediate_kw_select"],
}),
),
copts = make_config(
name = "inner_config_name",
opt = "//:bad/in_named_value_fn",
),
msg = (
"//:bad/multi"
+ "_line"
),
)
exports_files(
["//:bad/in_exports_files"],
visibility = select({
"//conditions:default": ["//:bad/in_exports_files_select"],
}),
)
expect_includes(deps = ["//:bad/in_expect_includes"])
register_extension_info(
name = "non_target_stmt_name",
label = "//:bad/in_non_target_with_name",
)
filegroup(
name = dynamic_name_var,
srcs = ["//:bad/in_dynamic_name_target"],
)
def _helper_fn():
return select({
"//conditions:default": ["//:bad/in_return_select"],
})
def _my_rule_impl(ctx):
out = ctx.actions.declare_file("//:bad/in_rule_impl_assign")
ctx.actions.run(executable = "//:bad/in_rule_impl_run")
ctx.actions.step(
name = "action_step_name",
cmd = "//:bad/in_rule_impl_named_stmt",
)
my_rule = rule(
implementation = _my_rule_impl,
attrs = {
"_tool": attr.label(default = "//:bad/in_rule_def_attr"),
},
doc = "//:bad/in_rule_def_doc",
)
if x == "//:ignored_in_compare":
fail("//:ignored_in_fail")
"""
tree = ast.parse(content, filename="src/defs.bzl")
ctx = framework.FileContext(
path="src/defs.bzl",
kind=framework.FileKind.BZL,
tree=tree,
lines=content.splitlines(),
)
# Loads
self.assertEqual(len(ctx.loads), 1)
self.assertEqual(ctx.loads[0].module_label, "//build:defs.bzl")
self.assertEqual(ctx.loads[0].raw_module_label, "//build:defs.bzl")
self.assertEqual(
[(local, exported) for local, exported, _ in ctx.loads[0].symbols],
[("my_macro", "my_macro"), ("_alias", "other")],
)
# Top-level calls (`is_top_level` is True only for call statements whose
# parent is `ast.Expr` directly under `ast.Module`; calls assigned to
# variables at module scope such as `my_rule = rule(...)`,
# `ASSIGNED_SELECT = select(...)`, and `ASSIGNED_NAMED = helper_with_name(...)`
# have `ast.Assign` as their parent and therefore have `is_top_level = False`).
top_level_calls = [c for c in ctx.calls if c.is_top_level]
self.assertEqual(
[c.func_name for c in top_level_calls],
[
"visibility",
"my_macro",
"exports_files",
"expect_includes",
"register_extension_info",
"filegroup",
],
)
macro_call = top_level_calls[1]
self.assertEqual(macro_call.target_name, "target_a")
self.assertIn("srcs", macro_call.keywords)
by_val = {s.value: s for s in ctx.strings}
self.assertTrue(by_val["//build:defs.bzl"].is_load_source)
self.assertFalse(by_val["//:bad/direct_arg"].is_load_source)
self.assertTrue(by_val["//:ignored_in_compare"].in_compare)
self.assertTrue(by_val["//:ignored_in_fail"].in_fail)
# Verify that StringInfo attributes match FileContext methods for every string.
for s in ctx.strings:
self.assertEqual(ctx.enclosing_call(s.node), s.enclosing_call)
self.assertEqual(
ctx.enclosing_statement_call(s.node), s.enclosing_statement_call
)
def call_summary(
call: framework.CallInfo | None,
) -> tuple[str, str | None] | None:
if call is None:
return None
return (call.func_name, call.target_name)
expected_enclosing_pairs = {
# SAME (None, None): load() source and symbol strings are represented by
# LoadInfo rather than CallInfo, so both functions return None.
"//build:defs.bzl": (None, None),
"my_macro": (None, None),
"other": (None, None),
# SAME (None, None): Top-level variable assignment outside any call.
"//:bad/top_var": (None, None),
# DIFFER: Call assigned to a variable (`ASSIGNED_SELECT = select(...)`).
# enclosing_call returns `select`, whereas enclosing_statement_call
# returns None because the statement is `ast.Assign`, not `ast.Expr`.
"//:bad/in_assigned_select": (("select", None), None),
# DIFFER: Named call assigned to a variable (`ASSIGNED_NAMED = helper_with_name(name = ...)`).
# enclosing_call returns `helper_with_name` (with target_name), whereas
# enclosing_statement_call returns None because the call is assigned.
"//:bad/in_assigned_named_call": (
("helper_with_name", "assigned_helper_name"),
None,
),
# SAME: Direct argument to a rule/macro statement call (`my_macro`).
"//:bad/direct_arg": (
("my_macro", "target_a"),
("my_macro", "target_a"),
),
# DIFFER: Nested inside `select(...)` within `my_macro`.
# enclosing_call is the inner `select`; enclosing_statement_call is `my_macro`.
"//:bad/in_select": (
("select", None),
("my_macro", "target_a"),
),
# DIFFER: Nested inside `glob(...)` within `my_macro`.
# enclosing_call is the inner `glob`; enclosing_statement_call is `my_macro`.
"//:bad/in_glob": (
("glob", None),
("my_macro", "target_a"),
),
# DIFFER: Nested inside `glob(...)` inside `select(...)` within `my_macro`.
# enclosing_call is the innermost `glob`; enclosing_statement_call is `my_macro`.
"//:bad/in_select_glob": (
("glob", None),
("my_macro", "target_a"),
),
# DIFFER: Argument to a value-returning helper function inside `my_macro`.
# enclosing_call is `compute_data`; enclosing_statement_call is `my_macro`.
"//:bad/in_value_fn": (
("compute_data", None),
("my_macro", "target_a"),
),
# DIFFER: Argument to a value-returning helper function that itself has a
# `name` parameter inside `my_macro`. enclosing_call is the inner
# `make_config` (target_name="inner_config_name"), while
# enclosing_statement_call is the outer `my_macro` (target_name="target_a").
"//:bad/in_named_value_fn": (
("make_config", "inner_config_name"),
("my_macro", "target_a"),
),
# SAME: Direct argument to a statement call without a `name` parameter
# (`exports_files`). Both return `exports_files` with target_name=None.
"//:bad/in_exports_files": (
("exports_files", None),
("exports_files", None),
),
# DIFFER: Nested inside `select(...)` within `exports_files(...)`.
# enclosing_call is `select`; enclosing_statement_call is `exports_files`.
"//:bad/in_exports_files_select": (
("select", None),
("exports_files", None),
),
# SAME: Direct argument to `expect_includes(...)` (no `name` parameter).
"//:bad/in_expect_includes": (
("expect_includes", None),
("expect_includes", None),
),
# SAME: Direct argument to a non-target statement call that happens to
# have a `name` parameter (`register_extension_info`).
"//:bad/in_non_target_with_name": (
("register_extension_info", "non_target_stmt_name"),
("register_extension_info", "non_target_stmt_name"),
),
# SAME: Direct argument to a target call whose `name` is a dynamic
# variable rather than a string literal (`target_name` is None).
"//:bad/in_dynamic_name_target": (
("filegroup", None),
("filegroup", None),
),
# DIFFER: Call inside a `return` statement (`return select(...)`).
# enclosing_call is `select`; enclosing_statement_call is None because
# the enclosing statement is `ast.Return`, not `ast.Expr`.
"//:bad/in_return_select": (("select", None), None),
# DIFFER: Assigned call inside a rule `implementation` function
# (`out = ctx.actions.declare_file(...)`). enclosing_call is
# `ctx.actions.declare_file`; enclosing_statement_call is None.
"//:bad/in_rule_impl_assign": (
("ctx.actions.declare_file", None),
None,
),
# SAME: Statement call without `name` inside a rule `implementation`
# function (`ctx.actions.run(...)`).
"//:bad/in_rule_impl_run": (
("ctx.actions.run", None),
("ctx.actions.run", None),
),
# SAME: Statement call with `name` inside a rule `implementation`
# function (`ctx.actions.step(name = "action_step_name", ...)`).
"//:bad/in_rule_impl_named_stmt": (
("ctx.actions.step", "action_step_name"),
("ctx.actions.step", "action_step_name"),
),
# DIFFER: Nested `attr.label(...)` inside `my_rule = rule(...)`.
# enclosing_call is `attr.label`; enclosing_statement_call is None
# because `my_rule = rule(...)` is an `ast.Assign` statement.
"//:bad/in_rule_def_attr": (("attr.label", None), None),
# DIFFER: Direct argument to `my_rule = rule(doc = ...)`.
# enclosing_call is `rule`; enclosing_statement_call is None because
# `rule(...)` is the right-hand side of an `ast.Assign` statement.
"//:bad/in_rule_def_doc": (("rule", None), None),
# SAME (None, None): String inside an `if` comparison condition.
"//:ignored_in_compare": (None, None),
# SAME: Argument to `fail(...)` statement call.
"//:ignored_in_fail": (("fail", None), ("fail", None)),
}
enclosing_pairs = {
val: (
call_summary(by_val[val].enclosing_call),
call_summary(by_val[val].enclosing_statement_call),
)
for val in expected_enclosing_pairs
}
self.assertEqual(enclosing_pairs, expected_enclosing_pairs)
expected_keyword_args = {
# Strings outside calls or in positional arguments have keyword_arg=None:
"//build:defs.bzl": None,
"my_macro": None,
"//:bad/top_var": None,
"//:bad/in_assigned_select": None,
"//:bad/in_exports_files": None,
"//:bad/in_return_select": None,
"//:bad/in_rule_impl_assign": None,
"//:ignored_in_compare": None,
"//:ignored_in_fail": None,
# Keyword argument on an assigned call (`enclosing` is `helper_with_name`,
# `enclosing_statement_call` is None):
"//:bad/in_assigned_named_call": "src",
# Direct keyword argument on a statement call (`my_macro`):
"//:bad/direct_arg": "srcs",
"//:bad/in_value_fn": "data",
# Keyword argument on `enclosing_statement_call` (`my_macro` or `exports_files`)
# when `enclosing` is a positional wrapper (`select(...)` or `glob(...)`):
"//:bad/in_select": "srcs",
"//:bad/in_glob": "srcs",
"//:bad/in_select_glob": "srcs",
"//:bad/in_exports_files_select": "visibility",
# When `enclosing` (`select`) is inside an intermediate helper call's
# keyword argument (`outer_helper(inner_kw = select(...))`) inside
# `enclosing_statement_call` (`my_macro(data = ...)`), `inner_kw` is
# skipped because `outer_helper` is neither `enclosing` nor
# `enclosing_statement_call`, resolving to `"data"` on `my_macro`:
"//:bad/in_intermediate_kw_select": "data",
# Keyword argument on inner `enclosing` call (`make_config(opt = ...)`)
# takes precedence over outer `enclosing_statement_call` (`copts = ...`):
"//:bad/in_named_value_fn": "opt",
# Keyword arguments on other statement calls and nested `attr.label`:
"//:bad/in_expect_includes": "deps",
"//:bad/in_non_target_with_name": "label",
"//:bad/in_dynamic_name_target": "srcs",
"//:bad/in_rule_impl_run": "executable",
"//:bad/in_rule_impl_named_stmt": "cmd",
"//:bad/in_rule_def_attr": "default",
"//:bad/in_rule_def_doc": "doc",
}
actual_keyword_args = {
val: by_val[val].keyword_arg for val in expected_keyword_args
}
self.assertEqual(actual_keyword_args, expected_keyword_args)
def test_normalize_repo_prefix_and_load_info(self) -> None:
self.assertEqual(
framework.normalize_repo_prefix("@rules_cc//cc:defs.bzl"),
"@rules_cc//cc:defs.bzl",
)
self.assertEqual(
framework.normalize_repo_prefix("@@rules_cc//cc:defs.bzl"),
"@rules_cc//cc:defs.bzl",
)
self.assertEqual(
framework.normalize_repo_prefix("//build:defs.bzl"),
"//build:defs.bzl",
)
self.assertEqual(
framework.normalize_repo_prefix("@//build:defs.bzl"),
"//build:defs.bzl",
)
self.assertEqual(
framework.normalize_repo_prefix("@@//build:defs.bzl"),
"//build:defs.bzl",
)
content = """
load("@@rules_cc//cc:defs.bzl", "cc_library", _cc_bin = "cc_binary")
load("@@//build/bazel/rules:defs.bzl", "my_rule")
"""
tree = ast.parse(content, filename="src/BUILD.bazel")
ctx = framework.FileContext(
path="src/BUILD.bazel",
kind=framework.FileKind.BUILD,
tree=tree,
lines=content.splitlines(),
)
self.assertEqual(len(ctx.loads), 2)
self.assertEqual(ctx.loads[0].module_label, "@rules_cc//cc:defs.bzl")
self.assertEqual(
ctx.loads[0].raw_module_label, "@@rules_cc//cc:defs.bzl"
)
self.assertEqual(
ctx.loads[1].module_label, "//build/bazel/rules:defs.bzl"
)
self.assertEqual(
ctx.loads[1].raw_module_label, "@@//build/bazel/rules:defs.bzl"
)
self.assertEqual(
ctx.loaded_symbols,
{
"cc_library": ("@rules_cc//cc:defs.bzl", "cc_library"),
"_cc_bin": ("@rules_cc//cc:defs.bzl", "cc_binary"),
"my_rule": ("//build/bazel/rules:defs.bzl", "my_rule"),
},
)
def test_automatic_target_attribution(self) -> None:
content = """
load(
"//bad:defs.bzl",
_rule = "bad_rule",
_const = "BAD_CONST",
_shared = "SHARED_CONST",
)
load(
"//partially_used:defs.bzl",
_used_sym = "USED_SYM",
_unused_sym = "UNUSED_IN_PARTIAL",
)
load("//outside:defs.bzl", _outside_sym = "OUTSIDE_SYM")
load("//unused:defs.bzl", _unused = "UNUSED")
TOP_VAR = "//bad:top_var"
ASSIGNED_SELECT = select({
"//conditions:default": ["//bad:in_assigned_select"],
})
ASSIGNED_NAMED = helper_with_name(
name = "assigned_helper_name",
src = "//bad:in_assigned_named_call",
)
_rule(
name = "target_one",
srcs = ["//bad:direct_arg", _shared, _used_sym],
deps = select({
"//conditions:default": ["//bad:in_select"],
}),
)
filegroup(
name = "target_two",
srcs = glob(["//bad:in_glob"]) + select({
"//conditions:default": glob(["//bad:in_select_glob"]),
}),
data = compute_data("//bad:in_value_fn") + [_const, _shared],
copts = make_config(
name = "inner_config_name",
opt = "//bad:in_named_value_fn",
),
)
exports_files(
["//bad:in_exports_files", _outside_sym],
visibility = select({
"//conditions:default": ["//bad:in_exports_files_select"],
}),
)
expect_includes(deps = ["//bad:in_expect_includes"])
register_extension_info(
name = "non_target_stmt_name",
label = "//bad:in_non_target_with_name",
)
filegroup(
name = dynamic_name_var,
srcs = ["//bad:in_dynamic_name_target"],
)
def _my_rule_impl(ctx):
out = ctx.actions.declare_file("//bad:in_rule_impl_assign")
ctx.actions.run(executable = "//bad:in_rule_impl_run")
ctx.actions.step(
name = "action_step_name",
cmd = "//bad:in_rule_impl_named_stmt",
)
my_rule = rule(
implementation = _my_rule_impl,
attrs = {
"_tool": attr.label(default = "//bad:in_rule_def_attr"),
},
doc = "//bad:in_rule_def_doc",
)
"""
class TargetAwareCheck(framework.BazelCheck):
name = "target_aware_check"
file_kinds = frozenset(
{framework.FileKind.BUILD, framework.FileKind.BZL}
)
supports_allowlist = True
supports_targets_allowlist = True
def check_load(
self, fc: framework.FileContext, load: framework.LoadInfo
) -> None:
for _, exported, arg_node in load.symbols:
fc.report(
f"bad symbol {exported}",
arg_node,
level="error",
url=None,
)
def check_call(
self, fc: framework.FileContext, call: framework.CallInfo
) -> None:
if call.func_name in ("_rule", "exports_files"):
fc.report(
f"bad call {call.func_name}",
call.node,
level="error",
url=None,
)
def check_string(
self, fc: framework.FileContext, s: framework.StringInfo
) -> None:
if s.value.startswith(
(
"//bad:",
"//partially_used:",
"//outside:",
"//unused:",
)
):
fc.report_string(
f"bad string {s.value}",
s.node,
s.value,
level="error",
url=None,
)
tree = ast.parse(content, filename="src/BUILD.bazel")
ctx = framework.FileContext(
path="src/BUILD.bazel",
kind=framework.FileKind.BUILD,
tree=tree,
lines=content.splitlines(),
supports_targets_allowlist=True,
)
check = TargetAwareCheck()
for load in ctx.loads:
check.check_load(ctx, load)
for call in ctx.calls:
check.check_call(ctx, call)
for s in ctx.strings:
check.check_string(ctx, s)
by_msg = {rf.finding["message"]: rf.targets for rf in ctx._raw_findings}
self.assertEqual(
by_msg,
{
# --- Loaded symbols (check_load) ---
# Loaded symbol used as the rule/macro callable for target_one:
"bad symbol bad_rule": ("target_one",),
# Loaded symbol referenced only inside target_two:
"bad symbol BAD_CONST": ("target_two",),
# Loaded symbol referenced inside both target_one and target_two
# (requires both targets to be allowlisted to suppress the load finding):
"bad symbol SHARED_CONST": ("target_one", "target_two"),
# Loaded symbol referenced only inside target_one:
"bad symbol USED_SYM": ("target_one",),
# Loaded symbol that is never referenced in the file cannot be
# attributed to any target:
"bad symbol UNUSED_IN_PARTIAL": (),
# Loaded symbol referenced inside a call without a `name` argument
# (`exports_files`) is not scoped to a named target, so it gets ():
"bad symbol OUTSIDE_SYM": (),
# Loaded symbol that is unused gets ():
"bad symbol UNUSED": (),
# --- Rule/function calls (check_call) ---
# Named rule/macro call (`_rule(name = "target_one", ...)`):
"bad call _rule": ("target_one",),
# Built-in function without a `name` attribute (`exports_files(...)`)
# does not define a named target, so its findings get () and cannot
# be suppressed by a `"targets"` allowlist entry:
"bad call exports_files": (),
# --- load() module label strings (check_string with is_load_source=True) ---
# All symbols loaded from `//bad:defs.bzl` (`_rule`, `_const`, `_shared`)
# are used exclusively by named targets `target_one` and `target_two`:
"bad string //bad:defs.bzl": ("target_one", "target_two"),
# NON-OBVIOUS: `//partially_used:defs.bzl` loads `_used_sym` (used by
# `target_one`) and `_unused_sym` (unused). Because `_unused_sym` is not
# scoped to a named target, the `load()` module label finding as a whole
# gets () and is not suppressed even if `target_one` is allowlisted:
"bad string //partially_used:defs.bzl": (),
# Symbol loaded from `//outside:defs.bzl` is used in `exports_files()`
# (which has no `name`), so the module label gets ():
"bad string //outside:defs.bzl": (),
# Symbol loaded from `//unused:defs.bzl` is unused, so the module label
# gets ():
"bad string //unused:defs.bzl": (),
# --- Strings in module-level assignments ---
# Top-level variable assignment outside any call:
"bad string //bad:top_var": (),
# String inside `select(...)` assigned to a module-level variable:
"bad string //bad:in_assigned_select": (),
# NON-OBVIOUS: String inside a call with `name = "assigned_helper_name"`
# that is assigned to a variable (`ASSIGNED_NAMED = helper_with_name(...)`).
# Because the statement is an assignment (`ast.Assign`) rather than a call
# statement (`ast.Expr`), `enclosing_statement_call` is None and targets is ():
"bad string //bad:in_assigned_named_call": (),
# --- Strings inside named target calls ---
# Direct string argument to `_rule(name = "target_one", ...)`:
"bad string //bad:direct_arg": ("target_one",),
# String nested inside `select({...})` in `target_one`:
"bad string //bad:in_select": ("target_one",),
# String nested inside `glob([...])` in `target_two`:
"bad string //bad:in_glob": ("target_two",),
# String nested inside `glob([...])` inside `select({...})` in `target_two`:
"bad string //bad:in_select_glob": ("target_two",),
# String passed to a normal value-returning helper function
# (`compute_data(...)`) inside `target_two`:
"bad string //bad:in_value_fn": ("target_two",),
# String passed to a value-returning helper function that happens to
# have its own `name = "inner_config_name"` parameter (`make_config(...)`)
# inside `target_two`. Attribution uses `enclosing_statement_call`
# (`filegroup`), so it resolves to `("target_two",)` rather than
# `("inner_config_name",)`:
"bad string //bad:in_named_value_fn": ("target_two",),
# --- Strings in calls without `name` or non-target calls ---
# Direct string in `exports_files(...)` (no `name` parameter):
"bad string //bad:in_exports_files": (),
# String inside `select(...)` in `exports_files(...)` (no `name` parameter):
"bad string //bad:in_exports_files_select": (),
# Direct string in `expect_includes(...)` (no `name` parameter):
"bad string //bad:in_expect_includes": (),
# NON-OBVIOUS: A statement-level function call (`register_extension_info`)
# that does not actually define a Bazel target in the build graph, but
# passes a literal `name = "non_target_stmt_name"` keyword argument.
# Because AST analysis cannot distinguish rules/macros from other
# statement calls with `name`, findings inside it are attributed to
# `("non_target_stmt_name",)`:
"bad string //bad:in_non_target_with_name": (
"non_target_stmt_name",
),
# NON-OBVIOUS: A target call (`filegroup(name = dynamic_name_var, ...)`)
# whose `name` is a variable (or expression like `"prefix_" + x`) rather
# than a string literal. Because the AST does not evaluate variables or
# fold expressions, `target_name` is None and findings get ():
"bad string //bad:in_dynamic_name_target": (),
# --- Strings in rule `implementation` functions and `rule()` definitions ---
# Assigned call (`out = ctx.actions.declare_file(...)`) inside
# `_my_rule_impl`: parent is `ast.Assign`, so targets is ():
"bad string //bad:in_rule_impl_assign": (),
# Statement call without `name` (`ctx.actions.run(...)`) inside
# `_my_rule_impl`: `target_name` is None, so targets is ():
"bad string //bad:in_rule_impl_run": (),
# NON-OBVIOUS: Statement call with a literal `name` argument
# (`ctx.actions.step(name = "action_step_name", ...)`) inside
# `_my_rule_impl`. Because `enclosing_statement_call` matches any
# `ast.Expr` call statement at any nesting depth, it attributes the
# finding to `("action_step_name",)`:
"bad string //bad:in_rule_impl_named_stmt": (
"action_step_name",
),
# String inside `attr.label(default = ...)` in `my_rule = rule(...)`:
# `my_rule = rule(...)` is an `ast.Assign` statement, so
# `enclosing_statement_call` is None and targets is ():
"bad string //bad:in_rule_def_attr": (),
# String in `doc = ...` of `my_rule = rule(...)`: parent of `rule(...)`
# is `ast.Assign`, so targets is ():
"bad string //bad:in_rule_def_doc": (),
},
)
# Verify end-to-end filtering in `run_check_on_contents` on a partially
# allowlisted file:
# 1. When only `target_one` is allowlisted, findings attributed solely to
# `target_one` (e.g. `bad symbol bad_rule`, `bad call _rule`,
# `bad string //bad:direct_arg`, `bad string //bad:in_select`,
# `bad symbol USED_SYM`) are suppressed, whereas findings shared with
# `target_two` (`bad symbol SHARED_CONST`, `bad string //bad:defs.bzl`),
# findings on `target_two`, and unattributed `()` findings remain reported.
with tempfile.TemporaryDirectory() as tmpdir:
al_path = Path(tmpdir) / "target_aware_check.json"
al_path.write_text(
json.dumps(
{
"entries": [
{
"bug": "https://fxbug.dev/570203009",
"path": "src/BUILD.bazel",
"targets": ["target_one"],
}
]
}
)
)
al_one = allowlist_mod.load_allowlist(
al_path, supports_targets_allowlist=True
)
findings_one = {
f["message"]
for f in framework.run_check_on_contents(
check, content, "src/BUILD.bazel", allowlist=al_one
)
}
for suppressed_msg in (
"bad symbol bad_rule",
"bad symbol USED_SYM",
"bad call _rule",
"bad string //bad:direct_arg",
"bad string //bad:in_select",
):
self.assertNotIn(suppressed_msg, findings_one)
for kept_msg in (
"bad symbol SHARED_CONST",
"bad symbol BAD_CONST",
"bad string //bad:defs.bzl",
"bad string //bad:in_glob",
"bad call exports_files",
"bad string //bad:in_exports_files",
):
self.assertIn(kept_msg, findings_one)
# 2. When both `target_one` and `target_two` are allowlisted, the
# shared load symbol (`SHARED_CONST`) and shared load module label
# (`//bad:defs.bzl`) are also suppressed, while unattributed `()`
# findings (such as `exports_files`) are still reported.
al_path.write_text(
json.dumps(
{
"entries": [
{
"bug": "https://fxbug.dev/570203009",
"path": "src/BUILD.bazel",
"targets": ["target_one", "target_two"],
}
]
}
)
)
al_both = allowlist_mod.load_allowlist(
al_path, supports_targets_allowlist=True
)
findings_both = {
f["message"]
for f in framework.run_check_on_contents(
check, content, "src/BUILD.bazel", allowlist=al_both
)
}
self.assertNotIn("bad symbol SHARED_CONST", findings_both)
self.assertNotIn("bad string //bad:defs.bzl", findings_both)
self.assertNotIn("bad string //bad:in_glob", findings_both)
self.assertIn("bad call exports_files", findings_both)
self.assertIn("bad string //bad:in_exports_files", findings_both)
def test_targets_using_symbol(self) -> None:
content = """\
load("//defs:defs.bzl", _rule = "RULE", _shared = "SHARED", _unused = "UNUSED")
load("//other:defs.bzl", _outside = "OUTSIDE")
_rule(name = "target_one", deps = [_shared])
filegroup(name = "target_two", deps = [_shared])
exports_files([_outside])
"""
tree = ast.parse(content, filename="src/BUILD.bazel")
ctx = framework.FileContext(
path="src/BUILD.bazel",
kind=framework.FileKind.BUILD,
tree=tree,
lines=content.splitlines(),
supports_targets_allowlist=True,
)
self.assertEqual(ctx._targets_using_symbol("_rule"), ("target_one",))
self.assertEqual(
ctx._targets_using_symbol("_shared"), ("target_one", "target_two")
)
self.assertEqual(ctx._targets_using_symbol("_unused"), ())
self.assertEqual(ctx._targets_using_symbol("_outside"), ())
self.assertEqual(ctx._targets_using_symbol("nonexistent_symbol"), ())
def test_targets_using_load(self) -> None:
content = """\
load("//used:defs.bzl", _rule = "RULE", _shared = "SHARED")
load("//outside:defs.bzl", _outside = "OUTSIDE")
load("//empty:defs.bzl")
_rule(name = "target_one", deps = [_shared])
filegroup(name = "target_two", deps = [_shared])
exports_files([_outside])
"""
tree = ast.parse(content, filename="src/BUILD.bazel")
ctx = framework.FileContext(
path="src/BUILD.bazel",
kind=framework.FileKind.BUILD,
tree=tree,
lines=content.splitlines(),
supports_targets_allowlist=True,
)
self.assertEqual(
ctx._targets_using_load(ctx.loads[0]), ("target_one", "target_two")
)
self.assertEqual(ctx._targets_using_load(ctx.loads[1]), ())
self.assertEqual(ctx._targets_using_load(ctx.loads[2]), ())
def test_infer_targets(self) -> None:
content = """\
load("//used:defs.bzl", _rule = "RULE", _shared = "SHARED")
_rule(name = "target_one", deps = [_shared])
filegroup(name = "target_two", deps = [_shared])
"""
tree = ast.parse(content, filename="src/BUILD.bazel")
ctx = framework.FileContext(
path="src/BUILD.bazel",
kind=framework.FileKind.BUILD,
tree=tree,
lines=content.splitlines(),
supports_targets_allowlist=True,
)
self.assertEqual(ctx._infer_targets(None), ())
self.assertEqual(
ctx._infer_targets(ctx.loads[0].node), ("target_one", "target_two")
)
ctx_no_targets = framework.FileContext(
path="src/BUILD.bazel",
kind=framework.FileKind.BUILD,
tree=tree,
lines=content.splitlines(),
supports_targets_allowlist=False,
)
self.assertEqual(ctx_no_targets._infer_targets(ctx.loads[0].node), ())
self.assertEqual(ctx_no_targets._infer_targets(ctx.calls[0].node), ())
def test_report_and_report_string_url(self) -> None:
content = 'my_macro(name = "t", srcs = ["//:bad"])\n'
tree = ast.parse(content, filename="src/BUILD.bazel")
ctx = framework.FileContext(
path="src/BUILD.bazel",
kind=framework.FileKind.BUILD,
tree=tree,
lines=content.splitlines(),
)
call = ctx.calls[0]
string_node = ctx.strings[1].node
ctx.report(
"Call message.",
call.node,
level="error",
url="https://example.com/docs#call",
)
ctx.report(
"Call message without URL.",
call.node,
level="warning",
url=None,
)
ctx.report_string(
"String message.",
string_node,
level="error",
url="https://example.com/docs#string",
)
ctx.report_string(
"String message without URL.",
string_node,
level="warning",
url=None,
)
self.assertEqual(
[rf.finding["message"] for rf in ctx._raw_findings],
[
"Call message. See https://example.com/docs#call.",
"Call message without URL.",
"String message. See https://example.com/docs#string.",
"String message without URL.",
],
)
def test_syntax_error_returns_finding(self) -> None:
findings = framework.run_check_on_contents(
SimpleCheckForTests(),
"def unclosed_parenthesis(",
"src/BUILD.bazel",
)
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0]["filepath"], "src/BUILD.bazel")
self.assertEqual(findings[0]["line"], 1)
self.assertEqual(findings[0]["col"], 25)
self.assertEqual(findings[0]["level"], "error")
self.assertIn("Syntax error:", findings[0]["message"])
def test_cli_clean_and_findings_and_errors(self) -> None:
with mock.patch("importlib.import_module"), mock.patch.dict(
framework.REGISTRY,
{"simple_check_for_tests": SimpleCheckForTests()},
clear=True,
), tempfile.TemporaryDirectory() as tmpdir:
clean_file = Path(tmpdir) / "BUILD.bazel"
clean_file.write_text('tool = "//foo:bar"\n')
with io.StringIO() as mock_stdout, mock.patch.object(
sys, "stdout", mock_stdout
):
ret = main.main(
[
"--check",
"simple_check_for_tests",
"--root",
str(tmpdir),
"--json",
str(clean_file),
]
)
self.assertEqual(ret, main.EXIT_OK)
self.assertEqual(json.loads(mock_stdout.getvalue()), [])
bad_file = Path(tmpdir) / "BUILD.bazel"
bad_file.write_text('tool = "//:bad/bar"\n')
with io.StringIO() as mock_stdout, mock.patch.object(
sys, "stdout", mock_stdout
):
ret = main.main(
[
"--check",
"simple_check_for_tests",
"--root",
str(tmpdir),
"--json",
str(bad_file),
]
)
self.assertEqual(ret, main.EXIT_FINDINGS)
data = json.loads(mock_stdout.getvalue())
self.assertEqual(len(data), 1)
with io.StringIO() as mock_stderr, mock.patch.object(
sys, "stderr", mock_stderr
):
ret = main.main(
[
"--check",
"simple_check_for_tests",
"--root",
str(tmpdir),
"nonexistent/BUILD.bazel",
]
)
self.assertEqual(ret, main.EXIT_ERROR)
self.assertIn("File not found", mock_stderr.getvalue())
if __name__ == "__main__":
unittest.main()