blob: 284a42f08c41fb7662d4e92d193fba55595d3b11 [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.
"""Checks Bazel files for disallowed workspace root package labels.
Labels in the workspace root package (e.g. `//:...`) should only refer to
targets or files defined directly in the root BUILD.bazel file. Disallowed labels
are indicated by a slash after the colon (e.g. `//:foo/bar`), which bypasses
package boundaries. Targets in subdirectories should instead be referenced via
their subpackage (e.g. `//foo:bar` or `//foo/bar`).
"""
import argparse
import ast
import json
import sys
from pathlib import Path
from typing import TypedDict
# Permitted workspace root package label prefixes for files in the main platform
# workspace. These subdirectories do not have BUILD.bazel files and their contents
# are exported directly from the root package by //build/bazel/toplevel.BUILD.bazel.
_WORKSPACE_ROOT_EXPORT_PREFIXES: tuple[str, ...] = (
# CIPD-downloaded prebuilts.
"//:prebuilt/",
# Generated build artifacts (e.g. jiri snapshot, IDK hash).
"//:fuchsia_build_generated/",
# A file exported by //build/bazel/toplevel.BUILD.bazel since the
# integration repositories do not have BUILD.bazel files.
"//:integration/MILESTONE",
)
# Permitted workspace root package labels for external or synthetic repositories
# (monolithic root workspaces). Several repository rules, toolchain templates, and
# synthetic workspaces construct standalone repositories where a single BUILD.bazel
# exists at the workspace root, and files in subdirectories belong directly to that
# workspace's root package without child BUILD.bazel files.
#
# Mapped by repository-relative file path to permitted label prefixes in that file.
_EXTERNAL_REPO_LABEL_PREFIXES: dict[str, tuple[str, ...]] = {
# Clang toolchain repository (@fuchsia_clang) generated by repository_utils.bzl.
"build/bazel_sdk/bazel_rules_fuchsia/common/toolchains/clang/repository_utils.bzl": (
"//:bin/",
),
# Rust toolchain repository (@prebuilt_rust) generated by rust.BUILD.bazel.
"build/bazel/toolchains/rust/rust.BUILD.bazel": ("//:bin/",),
# Standalone temporary workspace for updating third-party Rust crates.
"build/bazel/update-rustc-third-party/BUILD.bazel": (
"//:rust_toolchain/bin/",
),
# Fuchsia SDK repository (@fuchsia_sdk) templates.
"build/bazel_sdk/bazel_rules_fuchsia/fuchsia/workspace/sdk_templates/fuchsia_sdk.BUILD.bazel": (
"//:arch/",
"//:meta/",
),
}
class _Finding(TypedDict):
filepath: str
line: int
col: int
end_col: int
message: str
level: str
def _get_correct_label(label: str) -> tuple[str, str]:
"""Computes the corrected subpackage label and the target package directory."""
if not label.startswith("//:"):
raise ValueError(
f"Expected root package label starting with '//:', got '{label}'"
)
stripped = label[3:].rstrip(":")
if "/" not in stripped:
raise ValueError(
f"Expected root package label containing '/', got '{label}'"
)
if ":" in stripped:
parts = stripped.split(":")
target_dir = parts[0]
correct = "//" + stripped
else:
parts = stripped.split("/")
target_dir = "/".join(parts[:-1])
correct = f"//{target_dir}:{parts[-1]}"
return correct, target_dir
def _is_label_permitted(label: str, rel_path: str) -> bool:
"""Checks whether a label is permitted in the given file context."""
if label.startswith(_WORKSPACE_ROOT_EXPORT_PREFIXES):
return True
for context, allowed_prefixes in _EXTERNAL_REPO_LABEL_PREFIXES.items():
if rel_path == context or (
context.endswith("/") and rel_path.startswith(context)
):
if label.startswith(allowed_prefixes):
return True
return False
def _check_file_contents(content: str, rel_path: str) -> list[_Finding]:
"""Checks file content for disallowed workspace root package labels."""
findings: list[_Finding] = []
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
)
findings.append(
_Finding(
filepath=rel_path,
line=line,
col=col,
end_col=end_col,
message=f"Syntax error: {e.msg}",
level="error",
)
)
return findings
lines = content.splitlines()
for node in ast.walk(tree):
if not (isinstance(node, ast.Constant) and isinstance(node.value, str)):
continue
label = node.value
# Labels in the workspace root package start with '//:'. If they contain a
# slash, they refer to paths in subdirectories instead of the root package.
if not label.startswith("//:"):
continue
stripped = label[3:].rstrip(":")
if "/" not in stripped or _is_label_permitted(label, rel_path):
continue
suggested_label, target_dir = _get_correct_label(label)
line_text = lines[node.lineno - 1]
col = line_text.find(label, node.col_offset) + 1
end_col = col + len(label)
msg = (
f"Disallowed workspace root package label: `{label}`. "
f"Use `{suggested_label}` instead, and add a `BUILD.bazel` file to `//{target_dir}` if needed."
)
findings.append(
_Finding(
filepath=rel_path,
line=node.lineno,
col=col,
end_col=end_col,
message=msg,
level="error",
)
)
findings.sort(key=lambda f: (f["line"], f["col"]))
return findings
def check_file(
filepath: Path | str, root: Path | None = None
) -> list[_Finding]:
"""Checks a single file on disk."""
path = Path(filepath)
if not path.is_absolute() and root:
actual_path = root / path
else:
actual_path = path
if not actual_path.is_file():
raise FileNotFoundError(f"File not found: {actual_path}")
content = actual_path.read_text(encoding="utf-8")
if root and actual_path.is_relative_to(root):
rel_path = actual_path.relative_to(root).as_posix()
else:
rel_path = path.as_posix()
return _check_file_contents(content, rel_path)
def main() -> int:
"""CLI entrypoint."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--root",
type=Path,
default=None,
help="Repository root directory against which relative paths are resolved.",
)
parser.add_argument(
"--json",
action="store_true",
help="Output findings as a JSON array.",
)
parser.add_argument(
"files",
nargs="*",
type=Path,
help="Files to check.",
)
args = parser.parse_args()
root = args.root.resolve() if args.root else None
all_findings: list[_Finding] = []
try:
for file_path in args.files:
all_findings.extend(check_file(file_path, root=root))
except Exception as e:
print(f"Error checking files: {e}", file=sys.stderr)
return 2
if args.json:
print(json.dumps(all_findings, indent=2))
else:
for f in all_findings:
print(
f"{f['filepath']}:{f['line']}:{f['col']}: [{f['level']}] {f['message']}"
)
return 1 if all_findings else 0
if __name__ == "__main__":
sys.exit(main())