| #!/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()) |