blob: a495af9b1b5d2b9665f10302c5bcb8918a856aaf [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.
"""Core AST traversal, semantic hooks, and span emission for Bazel lint checks."""
import ast
import dataclasses
import enum
from pathlib import PurePosixPath
from typing import Sequence, TypedDict
from allowlist import Allowlist
# LINT.IfChange(file_kinds)
class FileKind(enum.Enum):
"""Supported file kinds for Bazel lint checks."""
# BUILD.bazel, *.BUILD.bazel, BUILD, *.BUILD
BUILD = "build"
# *.bzl
BZL = "bzl"
# MODULE.bazel, *.MODULE.bazel
MODULE = "module"
# WORKSPACE, WORKSPACE.bazel, WORKSPACE.bzlmod, *.WORKSPACE.bazel
WORKSPACE = "workspace"
# *.py (Starlark load() statements do not exist in Python; check_load is disallowed)
PYTHON = "python"
# LINT.ThenChange(//scripts/shac/bazel.star:file_globs)
ALL_BAZEL_FILE_KINDS: frozenset[FileKind] = frozenset(
{
FileKind.BUILD,
FileKind.BZL,
FileKind.MODULE,
FileKind.WORKSPACE,
}
)
def classify_file(rel_path: str) -> FileKind:
"""Classifies a repository-relative path into a `FileKind`.
Raises `ValueError` if the path does not match a known file kind.
"""
name = PurePosixPath(rel_path).name
if (
name in ("BUILD.bazel", "BUILD")
or name.endswith(".BUILD.bazel")
or name.endswith(".BUILD")
):
return FileKind.BUILD
if name.endswith(".bzl"):
return FileKind.BZL
if name == "MODULE.bazel" or name.endswith(".MODULE.bazel"):
return FileKind.MODULE
if name in (
"WORKSPACE",
"WORKSPACE.bazel",
"WORKSPACE.bzlmod",
) or name.endswith(".WORKSPACE.bazel"):
return FileKind.WORKSPACE
if name.endswith(".py"):
return FileKind.PYTHON
raise ValueError(f"Unsupported file kind for path: {rel_path}")
class Finding(TypedDict, total=False):
"""JSON-serializable shac finding."""
filepath: str
line: int
end_line: int
col: int
end_col: int
message: str
level: str
@dataclasses.dataclass(frozen=True)
class _RawFinding:
finding: Finding
targets: tuple[str, ...]
def normalize_repo_prefix(value: str) -> str:
"""Normalizes leading canonical repository prefixes (`@@` and `@//`).
Converts `"@@repo//..."` to `"@repo//..."` and `"@//..."` or `"@@//..."` to
`"//..."` so checks do not need to duplicate `@` and `@@` strings.
"""
if value.startswith("@@"):
value = value[1:]
if value.startswith("@//"):
return value[1:]
return value
# LINT.IfChange(load_info)
@dataclasses.dataclass(frozen=True)
class LoadInfo:
"""Pre-extracted metadata for a `load()` call passed to `check_load`.
Attributes:
node (ast.Call): The underlying AST `Call` node for the `load()` statement.
module_label (str): The normalized label of the `.bzl` module being
loaded (first argument to `load()`, with leading `@@` normalized to
`@` and `@//` normalized to `//`, e.g. `"@rules_cc//cc:defs.bzl"`).
raw_module_label (str): The exact un-normalized module label string as
written in the `load()` statement (e.g. `"@@rules_cc//cc:defs.bzl"`).
symbols (tuple[tuple[str, str, ast.AST], ...]): Tuple of loaded symbols,
where each entry is `(local_name, exported_name, symbol_ast_node)`.
`local_name` matches `exported_name` for positional string arguments,
or the keyword argument name when aliased (e.g. `foo = "bar"`).
"""
node: ast.Call
module_label: str
raw_module_label: str
# Each entry is (local_name, exported_name, symbol_ast_node).
symbols: tuple[tuple[str, str, ast.AST], ...]
# LINT.ThenChange(//scripts/shac/bazel_lint/checks/README.md:load_info)
# LINT.IfChange(call_info)
@dataclasses.dataclass(frozen=True)
class CallInfo:
"""Pre-extracted metadata for a function/rule/macro call passed to `check_call`.
All function calls, rule invocations, and macros are represented as
`CallInfo`. The AST cannot distinguish between a rule and a macro; they are
syntactically identical call expressions.
Attributes:
node (ast.Call): The underlying AST `Call` node.
func_name (str): The normalized function name. For direct calls
(e.g. `cc_library(...)` or `my_macro(...)`), `func_name` matches
the function identifier. For calls via `native.<rule>` (e.g.
`native.cc_library(...)`), `func_name` is normalized to strip
`native.` (becoming `"cc_library"`). Almost all checks should use
`func_name` so they handle both forms uniformly.
raw_func_name (str): The exact un-normalized function expression as source
text (e.g. `"native.cc_library"` or `"my_macro"`). This is identical
to `func_name` for all calls except those using the `native.` prefix.
Only use this when a check explicitly needs to distinguish
`native.<func>` from `<func>`.
target_name (str | None): The value of the `name = "..."` keyword argument
if present as a string literal, otherwise `None`.
keywords (dict[str, ast.keyword]): Mapping from keyword argument name to
its `ast.keyword` AST node.
args (tuple[ast.expr, ...]): Positional argument AST expressions.
forwards_kwargs (bool): True if the call passes `**kwargs` (e.g. in a macro).
is_top_level (bool): True if the call is an expression statement at the file's
top level (i.e. not nested inside a function definition, if, or loop).
"""
node: ast.Call
func_name: str
raw_func_name: str
target_name: str | None
keywords: dict[str, ast.keyword]
args: tuple[ast.expr, ...]
forwards_kwargs: bool
is_top_level: bool
# LINT.ThenChange(//scripts/shac/bazel_lint/checks/README.md:call_info)
# LINT.IfChange(string_info)
@dataclasses.dataclass(frozen=True)
class StringInfo:
"""Pre-extracted metadata for a string literal passed to `check_string`.
`check_string` only inspects string literals (represented in the AST as
`ast.Constant` with a `str` value). It cannot evaluate variables, string
formatting (e.g. `%` formatting or `.format()`), or concatenation involving
variables (e.g. `prefix + "/foo"`). When literal strings are concatenated
with `+` (e.g. `"foo" + "bar"`), each operand is a distinct `ast.Constant`
node; the AST does not perform constant folding, so `check_string` is
invoked separately for `"foo"` and `"bar"`, and neither sees `"foobar"`.
In contrast, adjacent string literals (e.g. `"foo" "bar"`) are merged into a
single `ast.Constant` (`"foobar"`) by the parser at parse time. Multiline
strings preserve newline characters (`\n`), and whitespace inside the string
is unmodified and un-normalized.
Attributes:
node (ast.Constant): The underlying AST `Constant` string node.
value (str): The decoded string value.
enclosing_call (CallInfo | None): The nearest (innermost) enclosing
non-`load()` call (such as `select(...)` or `glob(...)`), or `None`
if the string is outside any function call or inside `load()`. Most
checks that want the rule or macro call owning an argument should
use `enclosing_statement_call` instead. (`load()` statements are
represented by `LoadInfo` rather than `CallInfo`.)
enclosing_statement_call (CallInfo | None): The enclosing
statement-level non-`load()` call (such as a rule or macro target
declaration in a `BUILD.bazel` file or macro body), skipping past
nested expression calls like `select(...)` or `glob(...)`, or `None`
if the string is not inside a call statement (e.g. in a module-level
assignment `DEPS = select(...)` or a `load()` statement).
keyword_arg (str | None): The keyword argument name if this string is passed
directly as or inside a keyword argument of `enclosing_call` or
`enclosing_statement_call` (e.g. `"visibility"` or `"doc"`).
is_load_source (bool): True if this string is the module label argument of a
`load()` statement.
is_docstring (bool): True if this string is a module, class, or function
docstring. Note that Bazel `doc = "..."` keyword arguments on rules,
macros, or attributes are not docstrings (`is_docstring == False`);
they have `keyword_arg == "doc"`.
in_compare (bool): True if this string is part of a comparison expression
(e.g. `if x == "//visibility:public":`).
in_fail (bool): True if this string is an argument to `fail(...)`.
"""
node: ast.Constant
value: str
enclosing_call: CallInfo | None
enclosing_statement_call: CallInfo | None
keyword_arg: str | None
is_load_source: bool
is_docstring: bool
in_compare: bool
in_fail: bool
# LINT.ThenChange(//scripts/shac/bazel_lint/checks/README.md:string_info)
def _byte_offset_to_char_col(line_text: str, byte_offset: int) -> int:
"""Converts a 0-based UTF-8 byte offset into a 1-based character column."""
if byte_offset <= 0:
return 1
encoded = line_text.encode("utf-8")
prefix = encoded[:byte_offset].decode("utf-8", errors="replace")
return len(prefix) + 1
def _collect_docstrings(tree: ast.Module) -> set[ast.AST]:
"""Returns the set of `ast.Constant` nodes that are module, class, or function docstrings."""
docstrings: set[ast.AST] = set()
for node in ast.walk(tree):
if isinstance(
node,
(ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef),
):
if (
node.body
and isinstance(node.body[0], ast.Expr)
and isinstance(node.body[0].value, ast.Constant)
and isinstance(node.body[0].value.value, str)
):
docstrings.add(node.body[0].value)
return docstrings
def _extract_func_names(func: ast.expr) -> tuple[str, str]:
"""Returns `(normalized_func_name, raw_func_name)` for a call's `func` node."""
if isinstance(func, ast.Name):
return func.id, func.id
raw = ast.unparse(func)
if (
isinstance(func, ast.Attribute)
and isinstance(func.value, ast.Name)
and func.value.id == "native"
):
return func.attr, raw
return raw, raw
# LINT.IfChange(file_context)
class FileContext:
"""Per-file AST context and finding emitter passed to `BazelCheck` hooks.
Attributes:
path (str): Repository-relative path of the file being linted.
kind (FileKind): The classified `FileKind` of the file (e.g. `FileKind.BUILD`).
tree (ast.Module): Parsed Python/Starlark AST root.
lines (list[str]): File content split into lines as strings without trailing
newlines (1-indexed line numbers correspond to `lines[lineno - 1]`).
loaded_symbols (dict[str, tuple[str, str]]): Map of local symbol name to
`(module_label, exported_symbol_name)` for all symbols imported via
`load()`, where `module_label` is normalized via
`normalize_repo_prefix()`.
loads (list[LoadInfo]): Pre-extracted `LoadInfo` metadata for all `load()`
calls in the file.
calls (list[CallInfo]): Pre-extracted `CallInfo` metadata for all non-`load()`
function/rule/macro calls.
strings (list[StringInfo]): Pre-extracted `StringInfo` metadata for all
string constants.
"""
def __init__(
self,
path: str,
kind: FileKind,
tree: ast.Module,
lines: list[str],
*,
supports_targets_allowlist: bool = False,
) -> None:
self.path = path
self.kind = kind
self.tree = tree
self.lines = lines
self._supports_targets_allowlist = supports_targets_allowlist
self._raw_findings: list[_RawFinding] = []
self._parents: dict[ast.AST, ast.AST] = {}
for parent in ast.walk(tree):
for child in ast.iter_child_nodes(parent):
self._parents[child] = parent
self._docstrings = _collect_docstrings(tree)
self.loaded_symbols: dict[str, tuple[str, str]] = {}
self.loads: list[LoadInfo] = []
self.calls: list[CallInfo] = []
self._call_by_node: dict[ast.Call, CallInfo] = {}
self._load_source_nodes: set[ast.AST] = set()
self._load_symbol_to_local_name: dict[ast.AST, str] = {}
self._load_node_to_load: dict[ast.AST, LoadInfo] = {}
self._extract_loads_and_calls()
self.strings: list[StringInfo] = self._extract_strings()
def parent(self, node: ast.AST) -> ast.AST | None:
"""Returns the parent AST node of `node`, or `None` for the root module."""
return self._parents.get(node)
def enclosing_call(self, node: ast.AST) -> CallInfo | None:
"""Returns the nearest (innermost) enclosing non-`load()` `CallInfo`, if any.
When `node` is nested inside an expression call such as `select(...)`
or `glob(...)`, this returns that inner call rather than the outer
rule or macro statement call. Most checks that want the rule or macro
invocation owning an argument or string should use
`enclosing_statement_call()` instead.
(`load()` statements are represented as `LoadInfo` in `loads` and
handled by `check_load()`, so they are not returned as `CallInfo`.)
"""
cur = self._parents.get(node)
while cur is not None:
if isinstance(cur, ast.Call) and cur in self._call_by_node:
return self._call_by_node[cur]
if isinstance(cur, ast.stmt):
break
cur = self._parents.get(cur)
return None
def enclosing_statement_call(self, node: ast.AST) -> CallInfo | None:
"""Returns the enclosing statement-level non-`load()` `CallInfo`, if any.
Walks up from `node` (inclusive) to the call that forms the enclosing
expression statement (an `ast.Call` whose parent is `ast.Expr`, such as
a rule or macro target declaration at module scope or inside a macro
body), skipping past nested expression calls like `select(...)` or
`glob(...)`. Returns `None` if `node` is not part of a call statement
(for example, when inside a variable assignment `DEPS = select(...)` or
a `load()` statement).
"""
cur: ast.AST | None = node
while cur is not None:
if (
isinstance(cur, ast.Call)
and cur in self._call_by_node
and isinstance(self._parents.get(cur), ast.Expr)
):
return self._call_by_node[cur]
if isinstance(cur, ast.stmt):
break
cur = self._parents.get(cur)
return None
def _extract_loads_and_calls(self) -> None:
for node in ast.walk(self.tree):
if not isinstance(node, ast.Call):
continue
func_name, raw_func_name = _extract_func_names(node.func)
if (
raw_func_name == "load"
and node.args
and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str)
):
raw_module_label = node.args[0].value
module_label = normalize_repo_prefix(raw_module_label)
self._load_source_nodes.add(node.args[0])
symbols: list[tuple[str, str, ast.AST]] = []
for arg in node.args[1:]:
if isinstance(arg, ast.Constant) and isinstance(
arg.value, str
):
symbols.append((arg.value, arg.value, arg))
self.loaded_symbols[arg.value] = (
module_label,
arg.value,
)
self._load_symbol_to_local_name[arg] = arg.value
for kw in node.keywords:
if (
kw.arg is not None
and isinstance(kw.value, ast.Constant)
and isinstance(kw.value.value, str)
):
symbols.append((kw.arg, kw.value.value, kw))
self.loaded_symbols[kw.arg] = (
module_label,
kw.value.value,
)
self._load_symbol_to_local_name[kw] = kw.arg
load_info = LoadInfo(
node=node,
module_label=module_label,
raw_module_label=raw_module_label,
symbols=tuple(symbols),
)
self.loads.append(load_info)
self._load_node_to_load[node] = load_info
self._load_node_to_load[node.args[0]] = load_info
else:
keywords = {
kw.arg: kw for kw in node.keywords if kw.arg is not None
}
target_name: str | None = None
if "name" in keywords:
name_val = keywords["name"].value
if isinstance(name_val, ast.Constant) and isinstance(
name_val.value, str
):
target_name = name_val.value
forwards_kwargs = any(kw.arg is None for kw in node.keywords)
parent_node = self._parents.get(node)
is_top_level = (
isinstance(parent_node, ast.Expr)
and self._parents.get(parent_node) is self.tree
)
call_info = CallInfo(
node=node,
func_name=func_name,
raw_func_name=raw_func_name,
target_name=target_name,
keywords=keywords,
args=tuple(node.args),
forwards_kwargs=forwards_kwargs,
is_top_level=is_top_level,
)
self.calls.append(call_info)
self._call_by_node[node] = call_info
def _extract_strings(self) -> list[StringInfo]:
strings: list[StringInfo] = []
for node in ast.walk(self.tree):
if not (
isinstance(node, ast.Constant) and isinstance(node.value, str)
):
continue
enclosing = self.enclosing_call(node)
enclosing_stmt_call = self.enclosing_statement_call(node)
keyword_arg: str | None = None
in_compare = False
# Walk up the AST from the string literal to the enclosing statement
# to determine whether the string appears inside a comparison (`==`,
# `in`, etc.) or inside a named keyword argument (`foo = ...`).
#
# For `keyword_arg`, record the first named keyword argument
# (`cur.arg is not None` excludes `**kwargs` unpacking) whose parent
# `ast.Call` is either:
# - `enclosing` (the innermost call containing the string, such as
# `my_rule(visibility = ["..."])` or
# `attr.label(default = "...")`), or
# - `enclosing_stmt_call` (the statement-level target/macro call, so
# that when a string is inside a helper call without keyword
# arguments, such as `select({...})` or `glob([...])` passed to
# `cc_library(name = "foo", visibility = select({...}))`, it
# still resolves `keyword_arg = "visibility"` from the outer call).
cur: ast.AST | None = node
while cur is not None:
if isinstance(cur, ast.Compare):
in_compare = True
if (
keyword_arg is None
and isinstance(cur, ast.keyword)
and cur.arg is not None
and (
(
enclosing is not None
and self._parents.get(cur) is enclosing.node
)
or (
enclosing_stmt_call is not None
and self._parents.get(cur)
is enclosing_stmt_call.node
)
)
):
keyword_arg = cur.arg
if isinstance(cur, ast.stmt):
break
cur = self._parents.get(cur)
in_fail = enclosing is not None and enclosing.func_name == "fail"
strings.append(
StringInfo(
node=node,
value=node.value,
enclosing_call=enclosing,
enclosing_statement_call=enclosing_stmt_call,
keyword_arg=keyword_arg,
is_load_source=node in self._load_source_nodes,
is_docstring=node in self._docstrings,
in_compare=in_compare,
in_fail=in_fail,
)
)
return strings
def _targets_using_symbol(self, local_name: str) -> tuple[str, ...]:
"""Returns target names of statement calls referencing `local_name`.
Returns an empty tuple if `local_name` is unused or is referenced
outside a named statement call (so target-scoped allowlists do not
suppress unused or module-level loads).
"""
targets: list[str] = []
for node in ast.walk(self.tree):
if isinstance(node, ast.Name) and node.id == local_name:
call = self.enclosing_statement_call(node)
if call is None or call.target_name is None:
return ()
if call.target_name not in targets:
targets.append(call.target_name)
return tuple(targets)
def _targets_using_load(self, load: LoadInfo) -> tuple[str, ...]:
"""Returns target names of statement calls referencing symbols loaded by `load`.
Returns an empty tuple if `load` loads no symbols or if any loaded
symbol is unused or referenced outside a named statement call.
"""
if not load.symbols:
return ()
targets: list[str] = []
for local_name, _exported_name, _sym_node in load.symbols:
sym_targets = self._targets_using_symbol(local_name)
if not sym_targets:
return ()
for target_name in sym_targets:
if target_name not in targets:
targets.append(target_name)
return tuple(targets)
def _infer_targets(self, node: ast.AST | None) -> tuple[str, ...]:
"""Infers the target name(s) associated with `node` for target-scoped allowlists."""
if not self._supports_targets_allowlist or node is None:
return ()
if node in self._load_symbol_to_local_name:
return self._targets_using_symbol(
self._load_symbol_to_local_name[node]
)
if node in self._load_node_to_load:
return self._targets_using_load(self._load_node_to_load[node])
call = self.enclosing_statement_call(node)
if call is not None and call.target_name is not None:
return (call.target_name,)
return ()
def report(
self,
message: str,
node: ast.AST | None = None,
*,
level: str,
url: str | None,
targets: Sequence[str] | None = None,
) -> None:
"""Emits a finding for `node` (or a file-level finding if `node` is `None`)."""
resolved_targets = (
tuple(targets) if targets is not None else self._infer_targets(node)
)
full_message = f"{message} See {url}." if url is not None else message
finding: Finding = {
"filepath": self.path,
"message": full_message,
"level": level,
}
lineno = getattr(node, "lineno", None)
col_offset = getattr(node, "col_offset", None)
if node is not None and lineno is not None and col_offset is not None:
end_lineno = getattr(node, "end_lineno", None) or lineno
end_col_offset = getattr(node, "end_col_offset", None)
start_line_text = (
self.lines[lineno - 1] if 1 <= lineno <= len(self.lines) else ""
)
end_line_text = (
self.lines[end_lineno - 1]
if 1 <= end_lineno <= len(self.lines)
else ""
)
col = _byte_offset_to_char_col(start_line_text, col_offset)
if end_col_offset is not None:
end_col = _byte_offset_to_char_col(
end_line_text, end_col_offset
)
else:
end_col = col + 1
finding["line"] = lineno
finding["col"] = col
if end_lineno > lineno:
finding["end_line"] = end_lineno
finding["end_col"] = end_col
self._raw_findings.append(
_RawFinding(finding=finding, targets=resolved_targets)
)
def report_string(
self,
message: str,
node: ast.Constant,
substring: str | None = None,
*,
level: str,
url: str | None,
targets: Sequence[str] | None = None,
) -> None:
"""Emits a finding highlighting `substring` inside a string literal `node`.
Excludes surrounding quotes on single-line strings and falls back to the
full `node` span for multi-line strings, implicit string concatenation,
or escaped literals.
"""
resolved_targets = (
tuple(targets) if targets is not None else self._infer_targets(node)
)
target_str = (
substring
if substring is not None
else (node.value if isinstance(node.value, str) else "")
)
lineno = getattr(node, "lineno", None)
end_lineno = getattr(node, "end_lineno", None) or lineno
col_offset = getattr(node, "col_offset", None)
if (
target_str
and lineno is not None
and end_lineno == lineno
and col_offset is not None
and 1 <= lineno <= len(self.lines)
):
line_text = self.lines[lineno - 1]
start_char_idx = _byte_offset_to_char_col(line_text, col_offset) - 1
found_idx = line_text.find(target_str, start_char_idx)
if found_idx != -1:
full_message = (
f"{message} See {url}." if url is not None else message
)
col = found_idx + 1
end_col = col + len(target_str)
self._raw_findings.append(
_RawFinding(
finding=Finding(
filepath=self.path,
line=lineno,
col=col,
end_col=end_col,
message=full_message,
level=level,
),
targets=resolved_targets,
)
)
return
self.report(
message, node, level=level, url=url, targets=resolved_targets
)
# LINT.ThenChange(//scripts/shac/bazel_lint/checks/README.md:file_context)
class BazelCheck:
"""Base class for pluggable AST-based Bazel lint checks."""
# Name of the check (e.g. "bazel_target_visibility"). Must match the check
# registration in //scripts/shac/bazel.star and allowlist filename.
name: str = ""
# The file kinds this check applies to (e.g. FileKind.BUILD, FileKind.BZL).
# NOTE: Files of each declared FileKind must be included in the globs
# specified in `_make_bazel_lint_check(..., applies_to_globs=...)` when
# registering the check in `//scripts/shac/bazel.star`. If a glob is omitted
# in `bazel.star`, shac will never pass those files to the linter.
file_kinds: frozenset[FileKind] = frozenset()
# Optional repository-relative glob patterns in Bazel `glob()` syntax (for
# example, `"build/bazel_sdk/bazel_rules_fuchsia/**"`) for paths that are
# permanently exempt by policy (no tracking bug required). Use
# `allowlists/<name>.json` instead for temporary exceptions that require a
# tracking bug.
policy_exempt_globs: tuple[str, ...] = ()
# Controls whether temporary bug-tracked exemptions in
# `allowlists/<name>.json` are permitted for this check. Defaults to `False`.
# Authors should determine whether there are reasonable use cases for
# allowing exceptions to the check, such as to unblock work where the check
# cannot be reasonably resolved, or whether the check is non-negotiable
# (such as `bazel_vendor_paths`, where vendor subpaths must never be
# referenced in public Fuchsia code).
supports_allowlist: bool = False
supports_targets_allowlist: bool = False
def is_path_exempt_by_policy(self, rel_path: str) -> bool:
"""Returns True if `rel_path` matches any glob in `policy_exempt_globs`.
Patterns use Bazel `glob()` syntax (evaluated via
`PurePosixPath.full_match`), where `*` matches within a single path
segment and `**` matches zero or more path segments (for example,
`"build/bazel_sdk/*rules*/**"` or `"build/bazel/rules/**"`).
"""
path = PurePosixPath(rel_path)
return any(
path.full_match(pattern) for pattern in self.policy_exempt_globs
)
def check_module(self, fc: FileContext) -> None:
"""Hook 1: Invoked once per file for whole-module assertions."""
def check_load(self, fc: FileContext, load: LoadInfo) -> None:
"""Hook 2: Invoked for every `load()` call in the file."""
def check_call(self, fc: FileContext, call: CallInfo) -> None:
"""Hook 3: Invoked for every non-`load()` call in the file."""
def check_string(self, fc: FileContext, s: StringInfo) -> None:
"""Hook 4: Invoked for every string constant in the file.
Only checks string literals (`ast.Constant` with a `str` value).
Variables, string formatting, or concatenation involving variables
(e.g. `prefix + "/foo"`) cannot be inspected. String literals
concatenated with `+` (e.g. `"foo" + "bar"`) appear as separate AST
nodes without constant folding (checked separately as `"foo"` and
`"bar"`), whereas adjacent string literals (e.g. `"foo" "bar"`) are
merged into a single node (`"foobar"`) by the parser at parse time.
Multiline strings preserve newline characters (`\n`), and whitespace is
unmodified and un-normalized.
Includes all string literals in the AST, including `load()` arguments
(where the `.bzl` module label has `s.is_load_source == True` and loaded
symbol names have `s.is_load_source == False`). Module, class, and
function docstrings are included and have `s.is_docstring == True`,
so checks that do not want to inspect docstrings can return early
(`if s.is_docstring: return`). Note that Bazel `doc = "..."` keyword
arguments on rules, macros, or attributes are regular string literals
with `s.is_docstring == False` and `s.keyword_arg == "doc"`. Comments
(`# ...`) are stripped by `ast.parse()` and are never passed to
`check_string()` or any other hook point function.
"""
REGISTRY: dict[str, BazelCheck] = {}
def register_check(check_cls: type[BazelCheck]) -> type[BazelCheck]:
"""Decorator that instantiates and registers a `BazelCheck` subclass."""
instance = check_cls()
if not instance.name:
raise ValueError(f"{check_cls.__name__} must define a non-empty 'name'")
if not instance.file_kinds:
raise ValueError(
f"{check_cls.__name__} must define non-empty 'file_kinds'"
)
for pattern in instance.policy_exempt_globs:
if pattern.endswith("/"):
raise ValueError(
f"{check_cls.__name__}: policy_exempt_globs pattern '{pattern}' "
"must use Bazel glob() syntax (e.g. 'dir/**' instead of 'dir/')"
)
if instance.supports_targets_allowlist and not instance.supports_allowlist:
raise ValueError(
f"{check_cls.__name__}: 'supports_targets_allowlist = True' "
"requires 'supports_allowlist = True'"
)
if (
FileKind.PYTHON in instance.file_kinds
and check_cls.check_load is not BazelCheck.check_load
):
raise TypeError(
f"{check_cls.__name__}: checks accepting FileKind.PYTHON cannot "
"override check_load (Python does not support Starlark load() statements)"
)
if instance.name in REGISTRY:
raise ValueError(f"Duplicate check registration for '{instance.name}'")
REGISTRY[instance.name] = instance
return check_cls
def run_check_on_contents(
check: BazelCheck,
content: str,
rel_path: str,
*,
allowlist: Allowlist | None = None,
) -> list[Finding]:
"""Runs `check` on in-memory `content` for `rel_path`."""
if not check.supports_allowlist and allowlist is not None:
raise ValueError(
f"Check '{check.name}' does not support allowlists (supports_allowlist = False)"
)
kind = classify_file(rel_path)
if kind not in check.file_kinds:
raise ValueError(
f"Check '{check.name}' does not accept {kind} ({rel_path})"
)
if check.is_path_exempt_by_policy(rel_path):
return []
if allowlist is not None and allowlist.is_file_allowlisted(rel_path):
return []
try:
tree = ast.parse(content, filename=rel_path)
except SyntaxError as e:
line = e.lineno or 1
col = e.offset or 1
end_col = (
e.end_offset if (e.end_offset and e.end_offset > col) else col + 1
)
return [
Finding(
filepath=rel_path,
line=line,
col=col,
end_col=end_col,
message=f"Syntax error: {e.msg}",
level="error",
)
]
except RecursionError:
return [
Finding(
filepath=rel_path,
line=1,
col=1,
end_col=2,
message="Syntax error: maximum AST recursion depth exceeded",
level="error",
)
]
fc = FileContext(
path=rel_path,
kind=kind,
tree=tree,
lines=content.splitlines(),
supports_targets_allowlist=check.supports_targets_allowlist,
)
check.check_module(fc)
for load in fc.loads:
check.check_load(fc, load)
for call in fc.calls:
check.check_call(fc, call)
for s in fc.strings:
check.check_string(fc, s)
results: list[Finding] = []
for raw in fc._raw_findings:
if allowlist is not None and allowlist.is_finding_allowlisted(
rel_path, raw.targets
):
continue
results.append(raw.finding)
results.sort(key=lambda f: (f.get("line", 0), f.get("col", 0)))
return results