blob: ee8e267bd0d9c89841d6d97f50992040187985f7 [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.
load("./common.star", "os_exec")
# FIDL library migration checks:
# 1. All the attributes in BUILD.bazel should have a mapped attribute in BUILD.gn.
# 2. All the attributes in BUILD.gn should be mapped to attributes in BUILD.bazel.
# 3. The copyright years in new created BUILD.bazel should be current year.
# 4. All migrated targets should be added to the `deps` list of `bazel2gn_verifications` group.
# 5. Migrated targets should be added to the targets_lists in `//sdk/fidl/category_lists.bzl`, according to its `category` and `stable` attributes.
# 6. The "//build/tools/bazel2gn/bazel_migration.gni" should be imported in the BUILD.gn of migrated libraries.
# 7. In the BUILD.bazel, the "fidl_library" should be loaded from "//build/bazel/rules/fidl:fidl_library.bzl".
# 8. visibility attribute should exist in both BUILD.bazel and BUILD.gn.
# 9. target name in BUILD.bazel should be the same as in BUILD.gn.
# 10. Attributes in BUILD.bazel and BUILD.gn should have the same values.
# Verified by changing copyright year to 2025.
# The Gerrit SHAC does report the expected warning. It confirms:
# 1. Current year can be obtained correctly.
# 2. The BUILD.bazel file is read correctly.
# 3. The copyright year checking works as expected.
def _check_copyright_year(ctx, path, content):
"""Checks if the copyright year of a newly created BUILD.bazel file is current year."""
# Get current year.
date_res = os_exec(ctx, ["/bin/date", "+%Y %m"]).wait()
date_parts = date_res.stdout.strip().split()
current_year = date_parts[0]
# Check Item 3
# The first line should start with "# Copyright".
lines = str(content).splitlines()
if len(lines) > 0:
line = lines[0]
if not line.startswith("# Copyright"):
ctx.emit.finding(
message = "First line of BUILD.bazel must be a copyright header.",
filepath = path,
level = "error",
line = 1,
)
elif not line.startswith("# Copyright " + current_year):
ctx.emit.finding(
message = "Copyright year should be '%s'. Please double check if the copyright year is correct." % current_year,
filepath = path,
level = "warning",
line = 1,
)
else:
ctx.emit.finding(
message = "BUILD.bazel file is empty or could not be read.",
filepath = path,
level = "error",
line = 1,
)
# Verified by changing a different target name in BUILD.gn.
# Errors are reported as expected. This confirms:
# 1. Both BUILD.bazel and BUILD.gn can be read correctly.
# 2. Target names are retrieved correctly.
# 3. Attributes and attribute values are retrieved and mapped correctly.
def _check_build_files(ctx, target_files):
"""Checks consistency of BUILD.bazel and BUILD.gn files for migrated FIDL libraries."""
# Only include the attributes which have different name strings in
# bazel and gn files into this mapping. Attributes not in this
# mapping are deemed to have the same name and value in both files.
bazel_to_gn_mapping = {
"srcs": "sources",
"deps": "public_deps",
"api_area": "sdk_area",
"category": "sdk_category",
"library_name": "name",
}
gn_to_bazel_mapping = {
"sources": "srcs",
"public_deps": "deps",
"sdk_area": "api_area",
"sdk_category": "category",
"name": "library_name",
}
# Apply the check to the libraries in current CL that have both BUILD.bazel and BUILD.gn.
# Exclude //sdk/fidl/BUILD.bazel and //sdk/fidl/BUILD.gn.
for file_info in target_files:
path = file_info.path
bazel_content = file_info.bazel_content
gn_content = file_info.gn_content
bazel_targets = file_info.bazel_targets
gn_targets = file_info.gn_targets
dir_path = _get_dir_path(path)
if not dir_path:
ctx.emit.finding(
message = "Cannot get directory path",
filepath = path,
level = "error",
)
continue
# Check Item 9, target names should be the same.
bazel_target_names = bazel_targets.keys()
gn_target_names = gn_targets.keys()
for t in bazel_target_names:
if t not in gn_target_names:
ctx.emit.finding(
message = "FIDL library target '%s' found in BUILD.bazel does not have a corresponding target in BUILD.gn." % t,
filepath = path,
level = "error",
)
for t in gn_target_names:
if t not in bazel_target_names:
ctx.emit.finding(
message = "FIDL library target '%s' found in BUILD.gn does not have a corresponding target in BUILD.bazel." % t,
filepath = path,
level = "error",
)
# Get the directory name. It's used for checking if the fidl target name is
# the same with directory name when it's under //sdk/fidl.
last_slash_idx = dir_path.rfind("/")
dir_name = dir_path[last_slash_idx + 1:] if last_slash_idx != -1 else dir_path
# For libraries under sdk/fidl/, the target name must match the directory name.
if path.startswith("sdk/fidl/"):
for t in bazel_target_names:
if t != dir_name:
ctx.emit.finding(
message = "Target name in BUILD.bazel ('%s') must match directory name ('%s') for libraries under sdk/fidl/." % (t, dir_name),
filepath = path,
level = "error",
)
# Check Item 7, BUILD.bazel must load fidl_library from //build/bazel/rules/fidl:fidl_library.bzl
load_matches = ctx.re.allmatches(r'load\(\s*"//build/bazel/rules/fidl:fidl_library.bzl"\s*,\s*"fidl_library"\s*\)', str(bazel_content))
if not load_matches:
ctx.emit.finding(
message = "BUILD.bazel must load fidl_library from //build/bazel/rules/fidl:fidl_library.bzl",
filepath = path,
level = "error",
)
# Check Item 6, BUILD.gn must import //build/tools/bazel2gn/bazel_migration.gni
if "import(\"//build/tools/bazel2gn/bazel_migration.gni\")" not in str(gn_content):
ctx.emit.finding(
message = "BUILD.gn must import //build/tools/bazel2gn/bazel_migration.gni",
filepath = path,
level = "error",
)
# This is the dictionary to keep targets and their attributes values in both gn and bazel build files.
combined_dict = {}
for target, attrs in bazel_targets.items():
for attr, val in attrs.items():
key = target + "-" + attr
combined_dict[key] = {"target": target, "attr": attr, "bazel": val, "gn": None}
for target, attrs in gn_targets.items():
for attr, val in attrs.items():
mapped_attr = gn_to_bazel_mapping.get(attr, attr)
key = target + "-" + mapped_attr
if key in combined_dict:
combined_dict[key]["gn"] = val
else:
combined_dict[key] = {"target": target, "attr": mapped_attr, "bazel": None, "gn": val}
# Ensure visibility is checked for all targets even if missing in both
for target in bazel_targets:
key = target + "-visibility"
if key not in combined_dict:
combined_dict[key] = {"target": target, "attr": "visibility", "bazel": None, "gn": None}
# Check Item 1 & 2, Attributes must match between BUILD.bazel and BUILD.gn
for key, item in combined_dict.items():
target = item["target"]
attr = item["attr"]
bazel_val = item["bazel"]
gn_val = item["gn"]
# Check visibility by _check_visibility() function.
if attr == "visibility":
_check_visibility(ctx, path, target, bazel_val, gn_val)
continue
# Skip name check, it's handled in Check Item 9.
if attr == "name":
continue
expected_gn_attr = bazel_to_gn_mapping.get(attr, attr)
if bazel_val == None:
ctx.emit.finding(
message = "Attribute '%s' in BUILD.gn of FIDL library target '%s' does not have a mapped attribute in BUILD.bazel." % (expected_gn_attr, target),
filepath = path,
level = "error",
)
elif gn_val == None:
ctx.emit.finding(
message = "Attribute '%s' in BUILD.bazel of FIDL library target '%s' does not have a mapped attribute in BUILD.gn." % (attr, target),
filepath = path,
level = "error",
)
else:
# Both are not None! Compare values!
if attr == "deps":
bazel_val = [_normalize_dep(d) for d in bazel_val]
gn_val = [_normalize_dep(d) for d in gn_val]
if type(bazel_val) == "list" and type(gn_val) == "list":
if sorted(bazel_val) != sorted(gn_val):
ctx.emit.finding(
message = "Attribute '%s' in BUILD.bazel of FIDL library target '%s' has different values from '%s' in BUILD.gn." % (attr, target, expected_gn_attr),
filepath = path,
level = "error",
)
elif bazel_val != gn_val:
ctx.emit.finding(
message = "Attribute '%s' in BUILD.bazel of FIDL library target '%s' has different values from '%s' in BUILD.gn." % (attr, target, expected_gn_attr),
filepath = path,
level = "error",
)
# Check Item 4
# Verify by removing the migrated target from the bazel2gn_verification_targets.gni in //sdk/fidl
# It confirms:
# 1. The BUILD.gn in //build directory is read correctly.
# 2. The bazel2gn_verification_targets.gni in //sdk/fidl directory is read correctly.
def _check_bazel2gn_verification_inclusion(ctx, target_files):
"""Checks if migrated targets are added to bazel2gn_verifications."""
# Get the locations of `bazel2gn_verification_targets.gni` files from `//build` directory.
build_gn_path = "build/BUILD.gn"
build_gn_content = ctx.io.read_file(ctx.scm.root + "/" + build_gn_path)
if build_gn_content == None:
ctx.emit.finding(
message = "Could not read //build/BUILD.gn",
filepath = build_gn_path,
level = "error",
)
return
# Extract all imports of bazel2gn_verification_targets.gni
bazel2gn_verification_targets_gni_import = []
for line in str(build_gn_content).splitlines():
matches = ctx.re.allmatches(r'^import\("//([^"]*bazel2gn_verification_targets\.gni)"\)', line)
if matches:
bazel2gn_verification_targets_gni_import.append(matches[0].groups[1])
if not bazel2gn_verification_targets_gni_import:
ctx.emit.finding(
message = "Could not find any imports of `bazel2gn_verification_targets.gni` in `//build/BUILD.gn`.",
filepath = build_gn_path,
level = "error",
)
return
# Apply the check to the libraries in current CL with both BUILD.bazel and BUILD.gn.
for file_info in target_files:
path = file_info.path
dir_path = _get_dir_path(path)
if not dir_path:
ctx.emit.finding(
message = "Could not determine directory path for file.",
filepath = path,
level = "error",
)
continue
# Find matching .gni file
matching_gni = ""
longest_prefix_len = -1
for imp in bazel2gn_verification_targets_gni_import:
gni_dir = imp[:imp.rfind("/")] if "/" in imp else ""
if path.startswith(gni_dir) and len(gni_dir) > longest_prefix_len:
matching_gni = imp
longest_prefix_len = len(gni_dir)
if not matching_gni:
matching_gni = "build/bazel2gn_verification_targets.gni"
# Read matching .gni file
gni_content = ctx.io.read_file(ctx.scm.root + "/" + matching_gni)
if gni_content == None:
ctx.emit.finding(
message = "Could not read `.gni` file '%s'." % matching_gni,
filepath = path,
level = "error",
)
continue
expected_dep = '"//%s:verify_bazel2gn"' % dir_path
if expected_dep not in str(gni_content):
ctx.emit.finding(
message = "Migrated target '%s' should be added to `%s`." % (expected_dep, matching_gni),
filepath = path,
level = "error",
)
# Check Item 5
def _check_sdk_fidl_list_inclusion(ctx, target_files):
"""Checks if migrated targets are added to target lists in sdk/fidl/category_lists.bzl."""
# Get //sdk/fidl/category_lists.bzl content
sdk_fidl_category_lists_path = "sdk/fidl/category_lists.bzl"
category_list_content = ctx.io.read_file(ctx.scm.root + "/" + sdk_fidl_category_lists_path)
if category_list_content == None:
ctx.emit.finding(
message = "Could not read //sdk/fidl/category_lists.bzl",
filepath = sdk_fidl_category_lists_path,
level = "error",
)
return
# Apply the check to the libraries in current CL with both BUILD.bazel and BUILD.gn.
# Exclude //sdk/fidl/BUILD.bazel and //sdk/fidl/BUILD.gn.
for file_info in target_files:
path = file_info.path
bazel_targets = file_info.bazel_targets
dir_path = _get_dir_path(path)
if not dir_path:
ctx.emit.finding(
message = "Could not determine directory path for file.",
filepath = path,
level = "error",
)
continue
if not bazel_targets:
ctx.emit.finding(
message = "Could not find any fidl_library targets in BUILD.bazel.",
filepath = path,
level = "error",
)
continue
# Go through all the fidl_library targets in BUILD.bazel.
for target_name, attrs in bazel_targets.items():
category = attrs.get("category")
stable = attrs.get("stable")
# Skip if both `category` and `stable` are not specified.
if category == None and stable == None:
continue
# Check if both `category` and `stable` are specified together.
if (category == None) != (stable == None):
ctx.emit.finding(
message = "Both `category` and `stable` must be specified together in BUILD.bazel for target '%s'." % target_name,
filepath = path,
level = "error",
)
continue
# Identify the invalid condition.
if stable == "false" and category != "partner":
ctx.emit.finding(
message = "`stable` must be true unless the category is 'partner' in BUILD.bazel for target '%s'." % target_name,
filepath = path,
level = "error",
)
continue
# Determine the list name based on `category` and `stable` attributes.
list_name = ""
if category == "partner":
if stable == "true":
list_name = "PARTNER_IDK_STABLE_FIDL_LIBRARY_ATOMS_LIST"
else:
list_name = "PARTNER_IDK_UNSTABLE_FIDL_LIBRARY_ATOMS_LIST"
elif category == "prebuilt":
list_name = "PREBUILT_FIDL_LIBRARY_ATOMS_LIST"
elif category == "host_tool":
list_name = "HOST_TOOL_FIDL_LIBRARY_ATOMS_LIST"
elif category == "compat_test":
list_name = "COMPAT_TEST_FIDL_LIBRARY_ATOMS_LIST"
if list_name:
list_block = _extract_bazel_target_list_block(str(category_list_content), list_name)
expected_target = '"//%s:%s_idk"' % (dir_path, target_name)
if not list_block or expected_target not in list_block:
ctx.emit.finding(
message = "Migrated target '%s' should be added to `%s` in `sdk/fidl/category_lists.bzl`." % (expected_target, list_name),
filepath = path,
level = "error",
)
def _get_dir_path(path):
"""Extracts the directory path from a file path."""
idx = path.rfind("/")
return path[:idx] if idx != -1 else ""
# Remove the comments in the content.
# Comment is :
# 1. Line comment: starts with "#"
# 2. Inline comment: starts with "#" and ends with "\n"
def _remove_comments(content):
lines = []
for line in content.splitlines():
if line.strip().startswith("#"):
continue
idx = line.find("#")
if idx == -1:
lines.append(line)
continue
# A `#` is found, exclude the `#` inside a string.
# Because `#` is valid in a directory name, so it could be in the label string.
in_double_quotes = False
in_single_quotes = False
escaped = False
comment_idx = -1
current_pos = 0
for _ in range(len(line)):
if idx == -1:
break
for i in range(current_pos, idx):
char = line[i]
# A quote just after a backslash will be treated as a normal character.
if escaped:
escaped = False
continue
if char == "\\":
escaped = True
continue
# Handle quotes.
if char == '"' and not in_single_quotes:
in_double_quotes = not in_double_quotes
elif char == "'" and not in_double_quotes:
in_single_quotes = not in_single_quotes
# If the `#` is not inside the quotes, it is the start of a comment.
if not in_double_quotes and not in_single_quotes:
comment_idx = idx
break
# The `#` is inside the quotes, continue to find the next `#`.
current_pos = idx + 1
idx = line.find("#", current_pos)
if comment_idx != -1:
lines.append(line[:comment_idx])
else:
lines.append(line)
return "\n".join(lines)
# Extract the fidl targets and attributes from BUILD.gn.
# FIDL target name is the string in fidl("target_name").
def _extract_gn_fidl_targets_map(ctx, content):
targets = {}
parts = content.split('fidl("')
for i in range(1, len(parts)):
# Get FIDL target name
part = parts[i]
idx = part.find('")')
# If `")` is not found, skip this part.
# If no target name is found, the caller will report error.
if idx == -1:
continue
target_name = part[:idx].strip()
# Get the attributes block. 2 = length of `")`
block_content = part[idx + 2:]
start_idx = block_content.find("{")
if start_idx != -1:
block_content = block_content[start_idx + 1:]
end_idx = block_content.find("\n}")
if end_idx != -1:
block_content = block_content[:end_idx]
# Retrieve attributes and their values from the attributes block
attrs = {}
attr_names = _extract_attributes(ctx, block_content)
for name in attr_names:
attrs[name] = _extract_attr_value(ctx, block_content, name)
targets[target_name] = attrs
return targets
# Extract the fidl_library targets and attributes from BUILD.bazel.
def _extract_bazel_fidl_targets_map(ctx, content):
targets = {}
parts = content.split("fidl_library(")
for i in range(1, len(parts)):
part = parts[i]
# Truncate the part to the end of the fidl_library block.
end_idx = part.find("\n)")
if end_idx != -1:
part = part[:end_idx]
matches = ctx.re.allmatches(r'name\s*=\s*"([^"]+)"', part)
if not matches:
matches = ctx.re.allmatches(r"name\s*=\s*'([^']+)'", part)
if not matches:
continue
target_name = matches[0].groups[1].strip()
attrs = {}
attr_names = _extract_attributes(ctx, part)
for name in attr_names:
attrs[name] = _extract_attr_value(ctx, part, name)
targets[target_name] = attrs
return targets
def _extract_attributes(ctx, content):
attrs = []
for line in content.splitlines():
matches = ctx.re.allmatches(r"^\s*([a-zA-Z_0-9]+)\s*=", line)
if matches:
attrs.append(matches[0].groups[1])
return attrs
def _extract_attr_value(ctx, content, attr_name):
# Try to find string value on a single line
for line in content.splitlines():
matches = ctx.re.allmatches(r"^\s*" + attr_name + r'\s*=\s*"([^"]*)"', line)
if matches:
return matches[0].groups[1]
matches = ctx.re.allmatches(r"^\s*" + attr_name + r"\s*=\s*'([^']*)'", line)
if matches:
return matches[0].groups[1]
matches = ctx.re.allmatches(r"^\s*" + attr_name + r"\s*=\s*(true|false|True|False)", line)
if matches:
val = matches[0].groups[1]
if val == "True":
return "true"
if val == "False":
return "false"
return val
# Try to extract list value
lines = content.splitlines()
for i in range(len(lines)):
line = lines[i]
if ctx.re.allmatches(r"^\s*" + attr_name + r"\s*=\s*\[", line):
values = []
matches = ctx.re.allmatches(r'["\']([^"\']+)["\']', line)
for m in matches:
values.append(m.groups[1])
if "]" in line:
return values
for j in range(i + 1, len(lines)):
next_line = lines[j]
if "]" in next_line:
idx = next_line.find("]")
part = next_line[:idx]
matches = ctx.re.allmatches(r'["\']([^"\']+)["\']', part)
for m in matches:
values.append(m.groups[1])
return values
else:
matches = ctx.re.allmatches(r'["\']([^"\']+)["\']', next_line)
for m in matches:
values.append(m.groups[1])
return values
return None
def _map_gn_visibility_to_bazel(item):
if item == "*":
return "//visibility:public"
if item == ":*":
return "//visibility:private"
if item.endswith("/*"):
return item[:-2] + ":__subpackages__"
if item.endswith(":*"):
return item[:-2] + ":__pkg__"
return item
# Checks if the visibility attributes in BUILD.bazel and BUILD.gn are equivalent.
# FIDL libraries under //sdk/fidl/ should have visibility attribute.
def _check_visibility(ctx, path, target, bazel_vis, gn_vis):
if not bazel_vis:
ctx.emit.finding(
message = "visibility attribute missing in BUILD.bazel for target `%s`" % target,
filepath = path,
level = "error",
)
if not gn_vis:
ctx.emit.finding(
message = "visibility attribute missing in BUILD.gn for target `%s`" % target,
filepath = path,
level = "error",
)
if bazel_vis and gn_vis:
mapped_gn_visibility = []
for v in gn_vis:
mapped_gn_visibility.append(_map_gn_visibility_to_bazel(v))
if sorted(bazel_vis) != sorted(mapped_gn_visibility):
ctx.emit.finding(
message = "Visibility in BUILD.bazel for target '%s' does not match mapped visibility from BUILD.gn.\n" % target +
"BUILD.bazel: %s\n" % bazel_vis +
"BUILD.gn (mapped): %s" % mapped_gn_visibility,
filepath = path,
level = "error",
)
def _normalize_dep(dep):
if ":" in dep:
idx = dep.find(":")
dir_part = dep[:idx]
target_part = dep[idx + 1:]
dir_idx = dir_part.rfind("/")
dir_leaf = dir_part[dir_idx + 1:] if dir_idx != -1 else dir_part
if target_part == dir_leaf:
return dir_part
return dep
def _extract_bazel_target_list_block(content, list_name):
# Try variable assignment list_name = [
idx = content.find(list_name + " = [")
if idx != -1:
end_idx = content.find("]", idx)
if end_idx != -1:
return content[idx:end_idx]
return ""
# The potential target file meets the criteria:
# 1. It is a BUILD.bazel file.
# 2. It is newly added or modified.
# 3. It has a corresponding BUILD.gn file in the same directory.
def _is_target_bazel_build_file(ctx, path, meta):
"""Returns True if the file is a newly added or modified FIDL BUILD.bazel file and the BUILD.gn is also in the directory."""
# TODO(https://fxbug.dev/487883318): Extend the check to FIDL libraries outside //sdk/fidl directory.
if not path.startswith("sdk/fidl/"):
return False
# Only process ADDED or MODIFIED files.
if meta.action not in ["A", "M"]:
return False
# Only process BUILD.bazel files to avoid duplicate checks.
if not path.endswith("BUILD.bazel"):
return False
# Skip the BUILD.bazel file in the //sdk/fidl/ directory.
if path == "sdk/fidl/BUILD.bazel":
return False
# Check if corresponding BUILD.gn file exists in the same directory.
gn_path = path.replace("BUILD.bazel", "BUILD.gn")
if not ctx.scm.all_files(glob = gn_path):
return False
return True
def fidl_gn2bazel_migration_check(ctx):
"""Main check for FIDL migration from GN to Bazel."""
target_files = []
for path, meta in ctx.scm.affected_files().items():
if not _is_target_bazel_build_file(ctx, path, meta):
continue
# Get fidl targets and attributes from BUILD.bazel.
# Skip the checks if it's not a fidl library.
orig_bazel_content = ctx.io.read_file(ctx.scm.root + "/" + path)
bazel_content = _remove_comments(str(orig_bazel_content))
bazel_targets = _extract_bazel_fidl_targets_map(ctx, bazel_content)
if not bazel_targets:
continue
if meta.action == "A":
_check_copyright_year(ctx, path, str(orig_bazel_content))
# Get fidl targets and attributes from BUILD.gn.
gn_path = path.replace("BUILD.bazel", "BUILD.gn")
gn_content = ctx.io.read_file(ctx.scm.root + "/" + gn_path)
gn_content = _remove_comments(str(gn_content))
gn_targets = _extract_gn_fidl_targets_map(ctx, gn_content)
# Because the BUILD.bazel has the fidl_library() definition,
# we expect the BUILD.gn to have the fidl() definition. If not, it's an error.
if not gn_targets:
ctx.emit.finding(
message = "BUILD.bazel has fidl_library() but BUILD.gn lacks fidl().",
filepath = path,
level = "error",
)
continue
target_files.append(struct(
path = path,
bazel_content = bazel_content,
gn_content = gn_content,
bazel_targets = bazel_targets,
gn_targets = gn_targets,
))
if not target_files:
return
_check_build_files(ctx, target_files)
_check_bazel2gn_verification_inclusion(ctx, target_files)
_check_sdk_fidl_list_inclusion(ctx, target_files)
def register_fidl_migration_checks():
shac.register_check(shac.check(fidl_gn2bazel_migration_check))