From b45c2defd277a566ec55980dd59b9ff1c2f6ecab Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 10 Sep 2026 20:34:04 +0200 Subject: [PATCH 1/7] feat(repo-policy-sync): manage Bazel dev dependencies --- .../docs/reference/policy-format.md | 19 ++ repo_policy_sync/src/models.py | 11 + repo_policy_sync/src/operations/README.md | 1 + repo_policy_sync/src/operations/__init__.py | 4 + .../ensure_bazel_dependency_dev_dependency.py | 260 ++++++++++++++++++ ..._ensure_bazel_dependency_dev_dependency.py | 178 ++++++++++++ 6 files changed, 473 insertions(+) create mode 100644 repo_policy_sync/src/operations/ensure_bazel_dependency_dev_dependency.py create mode 100644 repo_policy_sync/tests/operations/test_ensure_bazel_dependency_dev_dependency.py diff --git a/repo_policy_sync/docs/reference/policy-format.md b/repo_policy_sync/docs/reference/policy-format.md index 856856a..bc5ec99 100644 --- a/repo_policy_sync/docs/reference/policy-format.md +++ b/repo_policy_sync/docs/reference/policy-format.md @@ -237,3 +237,22 @@ commented-out dependencies do not count. Duplicate dependencies and malformed existing versions are rejected. The version may be a literal `X.Y.Z` string or an explicit reference to a policy-local value source, for example `version: {ref: devcontainer_version}`. + +### `ensure_bazel_dependency_dev_dependency` + +```yaml +- type: ensure_bazel_dependency_dev_dependency + module_file: MODULE.bazel + module_name: vsps_quality_packages + dev_dependency: false +``` + +Ensures that one existing direct `bazel_dep` has the configured development-only +setting. With `dev_dependency: true`, the operation adds the attribute when it +is missing and changes an explicit `False` to `True`. With +`dev_dependency: false`, it removes an explicit boolean `dev_dependency` +attribute, including an explicit `False`; an omitted attribute is the compliant +production form. Commented +calls and arguments do not count. Duplicate target dependencies or duplicate +`dev_dependency` arguments are rejected. The operation preserves the existing +argument order and formatting style as far as possible, and is idempotent. diff --git a/repo_policy_sync/src/models.py b/repo_policy_sync/src/models.py index 8a931f5..f9cc625 100644 --- a/repo_policy_sync/src/models.py +++ b/repo_policy_sync/src/models.py @@ -141,12 +141,23 @@ class EnsureBazelDependency: rationale: str | None = None +@dataclass(frozen=True) +class EnsureBazelDependencyDevDependency: + """Ensure the ``dev_dependency`` setting of a direct bzlmod dependency.""" + + module_file: Path + module_name: str + dev_dependency: bool + rationale: str | None = None + + EnsureOperation = ( EnsureLine | RemoveFile | ReplaceRegex | EnsureMinimumVersion | EnsureBazelDependency + | EnsureBazelDependencyDevDependency ) diff --git a/repo_policy_sync/src/operations/README.md b/repo_policy_sync/src/operations/README.md index 5216d3f..d35a1ce 100644 --- a/repo_policy_sync/src/operations/README.md +++ b/repo_policy_sync/src/operations/README.md @@ -27,6 +27,7 @@ authoritative source for the complete schema, validation rules, and examples. | Operation | Use it for | Main behavior | Tests | | --- | --- | --- | --- | | `ensure_bazel_dependency` | Declaring a direct bzlmod dependency | Adds a `bazel_dep` with the configured module name and version when it is missing. | [`test_ensure_bazel_dependency.py`](../../tests/operations/test_ensure_bazel_dependency.py) | +| `ensure_bazel_dependency_dev_dependency` | Controlling whether a direct bzlmod dependency is development-only | Adds or changes `dev_dependency = True`, or removes the attribute when configured as false. | [`test_ensure_bazel_dependency_dev_dependency.py`](../../tests/operations/test_ensure_bazel_dependency_dev_dependency.py) | | `ensure_line` | Keeping one exact line in a text file | Inserts the desired line, removes configured replacements and duplicates, and creates a missing file. | [`test_ensure_line.py`](../../tests/operations/test_ensure_line.py) | | `ensure_minimum_version` | Maintaining a simple version file such as `.bazelversion` | Replaces a lower `major.minor.patch` value; equal or higher versions and missing files are compliant. | [`test_ensure_minimum_version.py`](../../tests/operations/test_ensure_minimum_version.py) | | `remove_file` | Removing an obsolete file | Deletes an existing file; a missing file is compliant and directories are rejected. | [`test_remove_file.py`](../../tests/operations/test_remove_file.py) | diff --git a/repo_policy_sync/src/operations/__init__.py b/repo_policy_sync/src/operations/__init__.py index e48e8b3..1f51ba7 100644 --- a/repo_policy_sync/src/operations/__init__.py +++ b/repo_policy_sync/src/operations/__init__.py @@ -23,6 +23,9 @@ from ..errors import RepoPolicySyncError from ..models import Change, EnsureOperation, ValueReference from .ensure_bazel_dependency import EnsureBazelDependencyOperation +from .ensure_bazel_dependency_dev_dependency import ( + EnsureBazelDependencyDevDependencyOperation, +) from .ensure_line import EnsureLineOperation from .ensure_minimum_version import EnsureMinimumVersionOperation from .remove_file import RemoveFileOperation @@ -54,6 +57,7 @@ def apply( _HANDLERS: tuple[OperationHandler, ...] = ( EnsureBazelDependencyOperation(), + EnsureBazelDependencyDevDependencyOperation(), EnsureLineOperation(), EnsureMinimumVersionOperation(), RemoveFileOperation(), diff --git a/repo_policy_sync/src/operations/ensure_bazel_dependency_dev_dependency.py b/repo_policy_sync/src/operations/ensure_bazel_dependency_dev_dependency.py new file mode 100644 index 0000000..7819de5 --- /dev/null +++ b/repo_policy_sync/src/operations/ensure_bazel_dependency_dev_dependency.py @@ -0,0 +1,260 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Ensure the ``dev_dependency`` setting of a direct bzlmod dependency.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ..bazel import mask_starlark_comments, starlark_call_ranges +from ..errors import PolicyError, RepoPolicySyncError +from ..models import ( + Change, + EnsureBazelDependencyDevDependency, + EnsureOperation, +) +from ._validation import ( + expect_keys, + optional_string, + required_string, + safe_relative_path, + validate_repository_path, +) + +_MODULE_NAME = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]*\Z") +_NAME_ARGUMENT = re.compile(r"\bname\s*=\s*[\"']([^\"']+)[\"']") +_DEV_DEPENDENCY_ARGUMENT = re.compile(r"\bdev_dependency\s*=\s*(True|False)\b") + + +@dataclass(frozen=True) +class _DependencyCall: + body_start: int + body_end: int + dev_dependency: re.Match[str] | None + + +class EnsureBazelDependencyDevDependencyOperation: + """Ensure one direct dependency is or is not development-only.""" + + operation_type = "ensure_bazel_dependency_dev_dependency" + operation_class = EnsureBazelDependencyDevDependency + + def parse( + self, raw: dict[str, Any], source: Path + ) -> EnsureBazelDependencyDevDependency: + expect_keys( + raw, + {"type", "module_file", "module_name", "dev_dependency", "rationale"}, + source, + ) + module_name = required_string(raw, "module_name", source) + if _MODULE_NAME.fullmatch(module_name) is None: + raise PolicyError( + f"policy {source}: module_name must be a valid Bazel module name" + ) + dev_dependency = raw.get("dev_dependency") + if not isinstance(dev_dependency, bool): + raise PolicyError(f"policy {source}: dev_dependency must be a boolean") + return EnsureBazelDependencyDevDependency( + module_file=safe_relative_path( + required_string(raw, "module_file", source), source + ), + module_name=module_name, + dev_dependency=dev_dependency, + rationale=optional_string(raw, "rationale", source), + ) + + def describe_changes( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> tuple[Change, ...]: + assert isinstance(operation, EnsureBazelDependencyDevDependency) + path = root / operation.module_file + _, dependency = _find_dependency(root, path, operation) + if _is_compliant(dependency, operation.dev_dependency): + return () + if operation.dev_dependency: + description = ( + f"set Bazel dependency {operation.module_name!r} dev_dependency to true" + ) + else: + description = ( + f"remove dev_dependency from Bazel dependency {operation.module_name!r}" + ) + return (Change(operation.module_file, description, operation.rationale),) + + def apply( + self, + root: Path, + operation: EnsureOperation, + *, + organization: str | None = None, + ) -> None: + assert isinstance(operation, EnsureBazelDependencyDevDependency) + path = root / operation.module_file + text, dependency = _find_dependency(root, path, operation) + if _is_compliant(dependency, operation.dev_dependency): + return + if operation.dev_dependency: + updated = _set_dev_dependency(text, dependency) + else: + updated = _remove_dev_dependency(text, dependency) + path.write_text(updated, encoding="utf-8") + + +def _find_dependency( + root: Path, path: Path, operation: EnsureBazelDependencyDevDependency +) -> tuple[str, _DependencyCall]: + validate_repository_path(root, path) + if not path.is_file(): + raise RepoPolicySyncError(f"{operation.module_file} must exist") + text = path.read_text(encoding="utf-8") + calls: list[_DependencyCall] = [] + for start, end in starlark_call_ranges(text, "bazel_dep"): + body = mask_starlark_comments(text[start:end]) + name_matches = [ + match + for match in _NAME_ARGUMENT.finditer(body) + if match.group(1) == operation.module_name + ] + if not name_matches: + continue + if len(name_matches) != 1: + raise RepoPolicySyncError( + f"{operation.module_file} bazel_dep for {operation.module_name!r} " + "must declare name exactly once" + ) + dev_matches = list(_DEV_DEPENDENCY_ARGUMENT.finditer(body)) + if len(dev_matches) > 1: + raise RepoPolicySyncError( + f"{operation.module_file} bazel_dep for {operation.module_name!r} " + "must declare dev_dependency at most once" + ) + calls.append( + _DependencyCall( + body_start=start, + body_end=end, + dev_dependency=dev_matches[0] if dev_matches else None, + ) + ) + if len(calls) > 1: + raise RepoPolicySyncError( + f"{operation.module_file} must contain at most one bazel_dep for " + f"{operation.module_name!r}" + ) + if not calls: + raise RepoPolicySyncError( + f"{operation.module_file} contains no bazel_dep for " + f"{operation.module_name!r}" + ) + return text, calls[0] + + +def _is_compliant(dependency: _DependencyCall, desired: bool) -> bool: + if dependency.dev_dependency is None: + return not desired + return desired and dependency.dev_dependency.group(1) == "True" + + +def _set_dev_dependency(text: str, dependency: _DependencyCall) -> str: + match = dependency.dev_dependency + if match is not None: + value_start = dependency.body_start + match.start(1) + value_end = dependency.body_start + match.end(1) + return text[:value_start] + "True" + text[value_end:] + + body = text[dependency.body_start : dependency.body_end] + if "\n" not in body and "\r" not in body: + content = body.rstrip(" \t") + separator = "" if content.endswith(",") else "," + insertion = f"{separator} dev_dependency = True" + return ( + text[: dependency.body_start] + + content + + insertion + + body[len(content) :] + + text[dependency.body_end :] + ) + + content = body.rstrip(" \t\r\n") + trailing = body[len(content) :] + newline = "\r\n" if "\r\n" in trailing else "\n" + close_indent = trailing.rsplit("\n", 1)[-1] if "\n" in trailing else "" + argument_indent = _argument_indent(body) + separator = "" if content.endswith(",") else "," + insertion = ( + f"{separator}{newline}{argument_indent}dev_dependency = True," + f"{newline}{close_indent}" + ) + return ( + text[: dependency.body_start] + + content + + insertion + + text[dependency.body_end :] + ) + + +def _remove_dev_dependency(text: str, dependency: _DependencyCall) -> str: + match = dependency.dev_dependency + assert match is not None + argument_start = dependency.body_start + match.start() + argument_end = dependency.body_start + match.end() + after = text[argument_end : dependency.body_end] + trailing_match = re.match(r"[ \t]*(?:,[ \t]*(?:\r?\n[ \t]*)?)?", after) + if trailing_match is not None and "," in trailing_match.group(0): + suffix_start = argument_end + trailing_match.end() + prefix = text[:argument_start] + # If the target is the final multiline argument, remove its whole line + # but retain the newline after its comma for the closing parenthesis. + if ( + not after[trailing_match.end() :].strip() + and "\n" in prefix[dependency.body_start :] + ): + line_start = text.rfind("\n", dependency.body_start, argument_start) + if line_start > dependency.body_start and text[line_start - 1] == "\r": + line_start -= 1 + comma_match = re.match(r"[ \t]*,", after) + assert comma_match is not None + suffix_start = argument_end + comma_match.end() + return text[:line_start] + text[suffix_start:] + # For an inline final argument, remove its preceding separator too so + # the call does not retain a dangling comma before the closing parenthesis. + elif ( + not after[trailing_match.end() :].strip() + and "\n" not in text[dependency.body_start : argument_start] + ): + preceding = re.search(r",[ \t]*$", prefix[dependency.body_start :]) + if preceding is not None: + argument_start = dependency.body_start + preceding.start() + return text[:argument_start] + text[suffix_start:] + + before = text[dependency.body_start : argument_start] + preceding = re.search(r",[ \t]*(?:\r?\n[ \t]*)?$", before) + if preceding is None: + raise RepoPolicySyncError( + "dev_dependency argument must be separated from another argument" + ) + separator_start = dependency.body_start + preceding.start() + return text[:separator_start] + text[argument_end:] + + +def _argument_indent(body: str) -> str: + match = re.search(r"(?m)^([ \t]+)\S", body) + return match.group(1) if match is not None else " " diff --git a/repo_policy_sync/tests/operations/test_ensure_bazel_dependency_dev_dependency.py b/repo_policy_sync/tests/operations/test_ensure_bazel_dependency_dev_dependency.py new file mode 100644 index 0000000..a3df687 --- /dev/null +++ b/repo_policy_sync/tests/operations/test_ensure_bazel_dependency_dev_dependency.py @@ -0,0 +1,178 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +from pathlib import Path + +import pytest + +from repo_policy_sync.src.errors import PolicyError, RepoPolicySyncError +from repo_policy_sync.src.models import EnsureBazelDependencyDevDependency +from repo_policy_sync.src.operations import apply, describe_changes +from repo_policy_sync.src.operations.ensure_bazel_dependency_dev_dependency import ( + EnsureBazelDependencyDevDependencyOperation, +) + + +def _operation(dev_dependency: object = False): + return EnsureBazelDependencyDevDependencyOperation().parse( + { + "type": "ensure_bazel_dependency_dev_dependency", + "module_file": "MODULE.bazel", + "module_name": "example_dependency", + "dev_dependency": dev_dependency, + }, + Path("policy.yml"), + ) + + +@pytest.mark.parametrize("dev_dependency", [None, "false", 0, 1]) +def test_parse_rejects_non_boolean_dev_dependency(dev_dependency: object) -> None: + with pytest.raises(PolicyError, match="dev_dependency must be a boolean"): + _operation(dev_dependency) + + +@pytest.mark.parametrize( + ("before", "after", "desired"), + [ + ( + 'bazel_dep(name = "example_dependency", version = "1.0.0")\n', + 'bazel_dep(name = "example_dependency", version = "1.0.0", dev_dependency = True)\n', + True, + ), + ( + """bazel_dep( + name = "example_dependency", + version = "1.0.0", +) +""", + """bazel_dep( + name = "example_dependency", + version = "1.0.0", + dev_dependency = True, +) +""", + True, + ), + ( + 'bazel_dep(name = "example_dependency", version = "1.0.0", dev_dependency = False)\n', + 'bazel_dep(name = "example_dependency", version = "1.0.0", dev_dependency = True)\n', + True, + ), + ( + 'bazel_dep(name = "example_dependency", dev_dependency = True, version = "1.0.0")\n', + 'bazel_dep(name = "example_dependency", version = "1.0.0")\n', + False, + ), + ( + 'bazel_dep(name = "example_dependency", version = "1.0.0", dev_dependency = True)\n', + 'bazel_dep(name = "example_dependency", version = "1.0.0")\n', + False, + ), + ( + 'bazel_dep(name = "example_dependency", version = "1.0.0", dev_dependency = False)\n', + 'bazel_dep(name = "example_dependency", version = "1.0.0")\n', + False, + ), + ( + """bazel_dep( + name = "example_dependency", + version = "1.0.0", + dev_dependency = True, +) +""", + """bazel_dep( + name = "example_dependency", + version = "1.0.0", +) +""", + False, + ), + ( + """bazel_dep( + dev_dependency = True, + name = "example_dependency", + version = "1.0.0", +) +""", + """bazel_dep( + name = "example_dependency", + version = "1.0.0", +) +""", + False, + ), + ], +) +def test_ensure_dev_dependency_setting_is_added_or_removed( + tmp_path: Path, before: str, after: str, desired: bool +) -> None: + module = tmp_path / "MODULE.bazel" + module.write_text(before, encoding="utf-8") + operation = _operation(desired) + + assert describe_changes(tmp_path, operation) + apply(tmp_path, operation) + + assert module.read_text(encoding="utf-8") == after + assert describe_changes(tmp_path, operation) == () + + +def test_ensure_dev_dependency_ignores_commented_arguments(tmp_path: Path) -> None: + module = tmp_path / "MODULE.bazel" + module.write_text( + """bazel_dep( + name = "example_dependency", + # dev_dependency = True, + version = "1.0.0", +) +""", + encoding="utf-8", + ) + + assert describe_changes(tmp_path, _operation()) == () + + +def test_ensure_dev_dependency_rejects_duplicate_target_dependencies( + tmp_path: Path, +) -> None: + module = tmp_path / "MODULE.bazel" + module.write_text( + """bazel_dep(name = "example_dependency", version = "1.0.0") +bazel_dep(name = "example_dependency", version = "1.1.0") +""", + encoding="utf-8", + ) + + with pytest.raises(RepoPolicySyncError, match="at most one bazel_dep"): + describe_changes(tmp_path, _operation()) + + +def test_ensure_dev_dependency_rejects_duplicate_attributes(tmp_path: Path) -> None: + module = tmp_path / "MODULE.bazel" + module.write_text( + """bazel_dep( + name = "example_dependency", + dev_dependency = True, + dev_dependency = False, +) +""", + encoding="utf-8", + ) + + with pytest.raises(RepoPolicySyncError, match="dev_dependency at most once"): + describe_changes(tmp_path, _operation()) + + +def test_operation_model_is_registered() -> None: + operation = _operation(True) + assert isinstance(operation, EnsureBazelDependencyDevDependency) From d16953468bbfced7eb0ad3a34cd688dcd7f0bd3c Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 10 Sep 2026 20:59:00 +0200 Subject: [PATCH 2/7] refactor(repo-policy-sync): fix Bazel module file --- .../docs/reference/policy-format.md | 9 ++--- repo_policy_sync/src/models.py | 1 - repo_policy_sync/src/operations/README.md | 2 +- .../ensure_bazel_dependency_dev_dependency.py | 33 +++++++++---------- ..._ensure_bazel_dependency_dev_dependency.py | 1 - 5 files changed, 22 insertions(+), 24 deletions(-) diff --git a/repo_policy_sync/docs/reference/policy-format.md b/repo_policy_sync/docs/reference/policy-format.md index bc5ec99..6bc179e 100644 --- a/repo_policy_sync/docs/reference/policy-format.md +++ b/repo_policy_sync/docs/reference/policy-format.md @@ -242,14 +242,15 @@ an explicit reference to a policy-local value source, for example ```yaml - type: ensure_bazel_dependency_dev_dependency - module_file: MODULE.bazel module_name: vsps_quality_packages dev_dependency: false ``` -Ensures that one existing direct `bazel_dep` has the configured development-only -setting. With `dev_dependency: true`, the operation adds the attribute when it -is missing and changes an explicit `False` to `True`. With +Ensures that one existing direct `bazel_dep` in the repository-root +`MODULE.bazel` has the configured development-only setting. The module file is +fixed because bzlmod declares repository dependencies in that conventional +root file. With `dev_dependency: true`, the operation adds the attribute when +it is missing and changes an explicit `False` to `True`. With `dev_dependency: false`, it removes an explicit boolean `dev_dependency` attribute, including an explicit `False`; an omitted attribute is the compliant production form. Commented diff --git a/repo_policy_sync/src/models.py b/repo_policy_sync/src/models.py index f9cc625..15f7eff 100644 --- a/repo_policy_sync/src/models.py +++ b/repo_policy_sync/src/models.py @@ -145,7 +145,6 @@ class EnsureBazelDependency: class EnsureBazelDependencyDevDependency: """Ensure the ``dev_dependency`` setting of a direct bzlmod dependency.""" - module_file: Path module_name: str dev_dependency: bool rationale: str | None = None diff --git a/repo_policy_sync/src/operations/README.md b/repo_policy_sync/src/operations/README.md index d35a1ce..8ce50bf 100644 --- a/repo_policy_sync/src/operations/README.md +++ b/repo_policy_sync/src/operations/README.md @@ -27,7 +27,7 @@ authoritative source for the complete schema, validation rules, and examples. | Operation | Use it for | Main behavior | Tests | | --- | --- | --- | --- | | `ensure_bazel_dependency` | Declaring a direct bzlmod dependency | Adds a `bazel_dep` with the configured module name and version when it is missing. | [`test_ensure_bazel_dependency.py`](../../tests/operations/test_ensure_bazel_dependency.py) | -| `ensure_bazel_dependency_dev_dependency` | Controlling whether a direct bzlmod dependency is development-only | Adds or changes `dev_dependency = True`, or removes the attribute when configured as false. | [`test_ensure_bazel_dependency_dev_dependency.py`](../../tests/operations/test_ensure_bazel_dependency_dev_dependency.py) | +| `ensure_bazel_dependency_dev_dependency` | Controlling whether a direct bzlmod dependency is development-only | Adds or changes `dev_dependency = True`, or removes the attribute when configured as false, in the repository-root `MODULE.bazel`. | [`test_ensure_bazel_dependency_dev_dependency.py`](../../tests/operations/test_ensure_bazel_dependency_dev_dependency.py) | | `ensure_line` | Keeping one exact line in a text file | Inserts the desired line, removes configured replacements and duplicates, and creates a missing file. | [`test_ensure_line.py`](../../tests/operations/test_ensure_line.py) | | `ensure_minimum_version` | Maintaining a simple version file such as `.bazelversion` | Replaces a lower `major.minor.patch` value; equal or higher versions and missing files are compliant. | [`test_ensure_minimum_version.py`](../../tests/operations/test_ensure_minimum_version.py) | | `remove_file` | Removing an obsolete file | Deletes an existing file; a missing file is compliant and directories are rejected. | [`test_remove_file.py`](../../tests/operations/test_remove_file.py) | diff --git a/repo_policy_sync/src/operations/ensure_bazel_dependency_dev_dependency.py b/repo_policy_sync/src/operations/ensure_bazel_dependency_dev_dependency.py index 7819de5..aed180b 100644 --- a/repo_policy_sync/src/operations/ensure_bazel_dependency_dev_dependency.py +++ b/repo_policy_sync/src/operations/ensure_bazel_dependency_dev_dependency.py @@ -31,10 +31,13 @@ expect_keys, optional_string, required_string, - safe_relative_path, validate_repository_path, ) +# bzlmod dependency declarations belong to the repository-root MODULE.bazel. +# Keep this path internal to the operation so every policy can focus on the +# dependency whose setting it governs instead of repeating an invariant path. +_MODULE_FILE = Path("MODULE.bazel") _MODULE_NAME = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]*\Z") _NAME_ARGUMENT = re.compile(r"\bname\s*=\s*[\"']([^\"']+)[\"']") _DEV_DEPENDENCY_ARGUMENT = re.compile(r"\bdev_dependency\s*=\s*(True|False)\b") @@ -58,7 +61,7 @@ def parse( ) -> EnsureBazelDependencyDevDependency: expect_keys( raw, - {"type", "module_file", "module_name", "dev_dependency", "rationale"}, + {"type", "module_name", "dev_dependency", "rationale"}, source, ) module_name = required_string(raw, "module_name", source) @@ -70,9 +73,6 @@ def parse( if not isinstance(dev_dependency, bool): raise PolicyError(f"policy {source}: dev_dependency must be a boolean") return EnsureBazelDependencyDevDependency( - module_file=safe_relative_path( - required_string(raw, "module_file", source), source - ), module_name=module_name, dev_dependency=dev_dependency, rationale=optional_string(raw, "rationale", source), @@ -86,8 +86,7 @@ def describe_changes( organization: str | None = None, ) -> tuple[Change, ...]: assert isinstance(operation, EnsureBazelDependencyDevDependency) - path = root / operation.module_file - _, dependency = _find_dependency(root, path, operation) + _, dependency = _find_dependency(root, operation) if _is_compliant(dependency, operation.dev_dependency): return () if operation.dev_dependency: @@ -98,7 +97,7 @@ def describe_changes( description = ( f"remove dev_dependency from Bazel dependency {operation.module_name!r}" ) - return (Change(operation.module_file, description, operation.rationale),) + return (Change(_MODULE_FILE, description, operation.rationale),) def apply( self, @@ -108,8 +107,8 @@ def apply( organization: str | None = None, ) -> None: assert isinstance(operation, EnsureBazelDependencyDevDependency) - path = root / operation.module_file - text, dependency = _find_dependency(root, path, operation) + path = root / _MODULE_FILE + text, dependency = _find_dependency(root, operation) if _is_compliant(dependency, operation.dev_dependency): return if operation.dev_dependency: @@ -120,11 +119,12 @@ def apply( def _find_dependency( - root: Path, path: Path, operation: EnsureBazelDependencyDevDependency + root: Path, operation: EnsureBazelDependencyDevDependency ) -> tuple[str, _DependencyCall]: + path = root / _MODULE_FILE validate_repository_path(root, path) if not path.is_file(): - raise RepoPolicySyncError(f"{operation.module_file} must exist") + raise RepoPolicySyncError(f"{_MODULE_FILE} must exist") text = path.read_text(encoding="utf-8") calls: list[_DependencyCall] = [] for start, end in starlark_call_ranges(text, "bazel_dep"): @@ -138,13 +138,13 @@ def _find_dependency( continue if len(name_matches) != 1: raise RepoPolicySyncError( - f"{operation.module_file} bazel_dep for {operation.module_name!r} " + f"{_MODULE_FILE} bazel_dep for {operation.module_name!r} " "must declare name exactly once" ) dev_matches = list(_DEV_DEPENDENCY_ARGUMENT.finditer(body)) if len(dev_matches) > 1: raise RepoPolicySyncError( - f"{operation.module_file} bazel_dep for {operation.module_name!r} " + f"{_MODULE_FILE} bazel_dep for {operation.module_name!r} " "must declare dev_dependency at most once" ) calls.append( @@ -156,13 +156,12 @@ def _find_dependency( ) if len(calls) > 1: raise RepoPolicySyncError( - f"{operation.module_file} must contain at most one bazel_dep for " + f"{_MODULE_FILE} must contain at most one bazel_dep for " f"{operation.module_name!r}" ) if not calls: raise RepoPolicySyncError( - f"{operation.module_file} contains no bazel_dep for " - f"{operation.module_name!r}" + f"{_MODULE_FILE} contains no bazel_dep for {operation.module_name!r}" ) return text, calls[0] diff --git a/repo_policy_sync/tests/operations/test_ensure_bazel_dependency_dev_dependency.py b/repo_policy_sync/tests/operations/test_ensure_bazel_dependency_dev_dependency.py index a3df687..d7d6196 100644 --- a/repo_policy_sync/tests/operations/test_ensure_bazel_dependency_dev_dependency.py +++ b/repo_policy_sync/tests/operations/test_ensure_bazel_dependency_dev_dependency.py @@ -27,7 +27,6 @@ def _operation(dev_dependency: object = False): return EnsureBazelDependencyDevDependencyOperation().parse( { "type": "ensure_bazel_dependency_dev_dependency", - "module_file": "MODULE.bazel", "module_name": "example_dependency", "dev_dependency": dev_dependency, }, From e210d3d5545ff7f7608c52aeab8cb1e3bab4fe20 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 10 Sep 2026 21:05:58 +0200 Subject: [PATCH 3/7] feat(repo-policy-sync): align Bazel dev dependencies --- repo_policy_sync/docs/reference/policy-format.md | 5 ++++- repo_policy_sync/policies/README.md | 1 + .../ensure_bazel_dependency_dev_dependency.py | 13 +++++++------ .../test_ensure_bazel_dependency_dev_dependency.py | 14 ++++++++++++++ 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/repo_policy_sync/docs/reference/policy-format.md b/repo_policy_sync/docs/reference/policy-format.md index 6bc179e..19ac107 100644 --- a/repo_policy_sync/docs/reference/policy-format.md +++ b/repo_policy_sync/docs/reference/policy-format.md @@ -256,4 +256,7 @@ attribute, including an explicit `False`; an omitted attribute is the compliant production form. Commented calls and arguments do not count. Duplicate target dependencies or duplicate `dev_dependency` arguments are rejected. The operation preserves the existing -argument order and formatting style as far as possible, and is idempotent. +argument order and formatting style as far as possible, and is idempotent. If +the named dependency is absent, the operation is compliant without making a +change; this allows one policy to govern a set of optional dependencies selected +by `when.bazel.any_direct_module_dependencies`. diff --git a/repo_policy_sync/policies/README.md b/repo_policy_sync/policies/README.md index c0317b8..20eb40a 100644 --- a/repo_policy_sync/policies/README.md +++ b/repo_policy_sync/policies/README.md @@ -28,6 +28,7 @@ use the [documentation index](../docs/README.md). | Policy | Responsibility | Typical lifecycle | | --- | --- | --- | +| `bazel-dependency-dev-dependency-alignment` | Keep selected Bazel toolchain and validation dependencies development-only while keeping `score_docs_as_code` in the normal dependency graph. | Baseline maintenance | | `docs-as-code-gitignore` | Update `score_docs_as_code` Git ignore entries and remove legacy configuration files. | One-time cleanup | | `minimal-bazel-module-declaration` | Keep `MODULE.bazel` limited to the repository-owned module name by removing version metadata. | One-time cleanup | | `minimum-bazel-version` | Upgrade repositories to at least Bazel `8.6.0` and regenerate the lockfile when required. | Baseline maintenance | diff --git a/repo_policy_sync/src/operations/ensure_bazel_dependency_dev_dependency.py b/repo_policy_sync/src/operations/ensure_bazel_dependency_dev_dependency.py index aed180b..c21fe92 100644 --- a/repo_policy_sync/src/operations/ensure_bazel_dependency_dev_dependency.py +++ b/repo_policy_sync/src/operations/ensure_bazel_dependency_dev_dependency.py @@ -87,7 +87,7 @@ def describe_changes( ) -> tuple[Change, ...]: assert isinstance(operation, EnsureBazelDependencyDevDependency) _, dependency = _find_dependency(root, operation) - if _is_compliant(dependency, operation.dev_dependency): + if dependency is None or _is_compliant(dependency, operation.dev_dependency): return () if operation.dev_dependency: description = ( @@ -109,7 +109,7 @@ def apply( assert isinstance(operation, EnsureBazelDependencyDevDependency) path = root / _MODULE_FILE text, dependency = _find_dependency(root, operation) - if _is_compliant(dependency, operation.dev_dependency): + if dependency is None or _is_compliant(dependency, operation.dev_dependency): return if operation.dev_dependency: updated = _set_dev_dependency(text, dependency) @@ -120,7 +120,7 @@ def apply( def _find_dependency( root: Path, operation: EnsureBazelDependencyDevDependency -) -> tuple[str, _DependencyCall]: +) -> tuple[str, _DependencyCall | None]: path = root / _MODULE_FILE validate_repository_path(root, path) if not path.is_file(): @@ -160,9 +160,10 @@ def _find_dependency( f"{operation.module_name!r}" ) if not calls: - raise RepoPolicySyncError( - f"{_MODULE_FILE} contains no bazel_dep for {operation.module_name!r}" - ) + # A policy can list several optional dependencies in one condition. + # The condition selects repositories containing at least one target; + # each operation is then a no-op for the other absent targets. + return text, None return text, calls[0] diff --git a/repo_policy_sync/tests/operations/test_ensure_bazel_dependency_dev_dependency.py b/repo_policy_sync/tests/operations/test_ensure_bazel_dependency_dev_dependency.py index d7d6196..ae8b46d 100644 --- a/repo_policy_sync/tests/operations/test_ensure_bazel_dependency_dev_dependency.py +++ b/repo_policy_sync/tests/operations/test_ensure_bazel_dependency_dev_dependency.py @@ -141,6 +141,20 @@ def test_ensure_dev_dependency_ignores_commented_arguments(tmp_path: Path) -> No assert describe_changes(tmp_path, _operation()) == () +def test_ensure_dev_dependency_ignores_missing_target_dependency( + tmp_path: Path, +) -> None: + module = tmp_path / "MODULE.bazel" + original = 'bazel_dep(name = "other_dependency", version = "1.0.0")\n' + module.write_text(original, encoding="utf-8") + + operation = _operation() + assert describe_changes(tmp_path, operation) == () + apply(tmp_path, operation) + + assert module.read_text(encoding="utf-8") == original + + def test_ensure_dev_dependency_rejects_duplicate_target_dependencies( tmp_path: Path, ) -> None: From 7f722b91f0a4f0df6f4561fdc02e99bf540ff7a0 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 10 Sep 2026 21:06:14 +0200 Subject: [PATCH 4/7] feat(repo-policy-sync): add Bazel dependency alignment policy --- .../mixed-dependencies/after/MODULE.bazel | 10 ++++ .../mixed-dependencies/before/MODULE.bazel | 10 ++++ .../no-target-dependencies/after/MODULE.bazel | 3 + .../before/MODULE.bazel | 3 + .../policy.yml | 58 +++++++++++++++++++ 5 files changed, 84 insertions(+) create mode 100644 repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/mixed-dependencies/after/MODULE.bazel create mode 100644 repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/mixed-dependencies/before/MODULE.bazel create mode 100644 repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/no-target-dependencies/after/MODULE.bazel create mode 100644 repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/no-target-dependencies/before/MODULE.bazel create mode 100644 repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/policy.yml diff --git a/repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/mixed-dependencies/after/MODULE.bazel b/repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/mixed-dependencies/after/MODULE.bazel new file mode 100644 index 0000000..c997275 --- /dev/null +++ b/repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/mixed-dependencies/after/MODULE.bazel @@ -0,0 +1,10 @@ +module(name = "example") + +bazel_dep(name = "score_bazel_cpp_toolchains", version = "1.0.0", dev_dependency = True) +bazel_dep(name = "aspect_rules_py", version = "1.0.0", dev_dependency = True) +bazel_dep(name = "buildifier_prebuilt", version = "1.0.0", dev_dependency = True) +bazel_dep(name = "aspect_rules_lint", version = "1.0.0", dev_dependency = True) +bazel_dep(name = "toolchains_llvm", version = "1.0.0", dev_dependency = True) +bazel_dep(name = "score_rules_imagefs", version = "1.0.0", dev_dependency = True) +bazel_dep(name = "score_docs_as_code", version = "1.0.0") +bazel_dep(name = "unrelated_dependency", version = "1.0.0") diff --git a/repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/mixed-dependencies/before/MODULE.bazel b/repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/mixed-dependencies/before/MODULE.bazel new file mode 100644 index 0000000..055aaeb --- /dev/null +++ b/repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/mixed-dependencies/before/MODULE.bazel @@ -0,0 +1,10 @@ +module(name = "example") + +bazel_dep(name = "score_bazel_cpp_toolchains", version = "1.0.0") +bazel_dep(name = "aspect_rules_py", version = "1.0.0", dev_dependency = False) +bazel_dep(name = "buildifier_prebuilt", version = "1.0.0") +bazel_dep(name = "aspect_rules_lint", version = "1.0.0", dev_dependency = True) +bazel_dep(name = "toolchains_llvm", version = "1.0.0") +bazel_dep(name = "score_rules_imagefs", version = "1.0.0", dev_dependency = False) +bazel_dep(name = "score_docs_as_code", version = "1.0.0", dev_dependency = True) +bazel_dep(name = "unrelated_dependency", version = "1.0.0") diff --git a/repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/no-target-dependencies/after/MODULE.bazel b/repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/no-target-dependencies/after/MODULE.bazel new file mode 100644 index 0000000..da0cdfe --- /dev/null +++ b/repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/no-target-dependencies/after/MODULE.bazel @@ -0,0 +1,3 @@ +module(name = "example") + +bazel_dep(name = "unrelated_dependency", version = "1.0.0") diff --git a/repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/no-target-dependencies/before/MODULE.bazel b/repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/no-target-dependencies/before/MODULE.bazel new file mode 100644 index 0000000..da0cdfe --- /dev/null +++ b/repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/no-target-dependencies/before/MODULE.bazel @@ -0,0 +1,3 @@ +module(name = "example") + +bazel_dep(name = "unrelated_dependency", version = "1.0.0") diff --git a/repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/policy.yml b/repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/policy.yml new file mode 100644 index 0000000..93fa527 --- /dev/null +++ b/repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/policy.yml @@ -0,0 +1,58 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +title: "chore(bazel): align development-only dependencies" +description: | + Keep Bazel toolchain, lint, image, and documentation dependencies in the + intended dependency graph. The listed build-support dependencies are + development-only, while score_docs_as_code is used by the normal build graph. + +when: + bazel: + any_direct_module_dependencies: + - score_bazel_cpp_toolchains + - aspect_rules_py + - buildifier_prebuilt + - aspect_rules_lint + - toolchains_llvm + - score_rules_imagefs + - score_docs_as_code + +ensure: + - type: ensure_bazel_dependency_dev_dependency + module_name: score_bazel_cpp_toolchains + dev_dependency: true + rationale: Toolchain setup is only needed for development and validation. + - type: ensure_bazel_dependency_dev_dependency + module_name: aspect_rules_py + dev_dependency: true + rationale: Python build rules are only needed for development and validation. + - type: ensure_bazel_dependency_dev_dependency + module_name: buildifier_prebuilt + dev_dependency: true + rationale: Buildifier is a development-time formatting tool. + - type: ensure_bazel_dependency_dev_dependency + module_name: aspect_rules_lint + dev_dependency: true + rationale: Lint rules are only needed for development and validation. + - type: ensure_bazel_dependency_dev_dependency + module_name: toolchains_llvm + dev_dependency: true + rationale: LLVM toolchains are only needed for development and validation. + - type: ensure_bazel_dependency_dev_dependency + module_name: score_rules_imagefs + dev_dependency: true + rationale: Image filesystem rules are only needed for development and validation. + - type: ensure_bazel_dependency_dev_dependency + module_name: score_docs_as_code + dev_dependency: false + rationale: Documentation rules are required by the normal build graph. From 3523ca039bc73743a8c72c6471a7be31f401a7f0 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 10 Sep 2026 21:13:52 +0200 Subject: [PATCH 5/7] refactor(repo-policy-sync): simplify alignment policy name --- repo_policy_sync/policies/README.md | 2 +- .../mixed-dependencies/after/MODULE.bazel | 0 .../mixed-dependencies/before/MODULE.bazel | 0 .../no-target-dependencies/after/MODULE.bazel | 0 .../no-target-dependencies/before/MODULE.bazel | 0 .../policy.yml | 0 6 files changed, 1 insertion(+), 1 deletion(-) rename repo_policy_sync/policies/{bazel-dependency-dev-dependency-alignment => dev-dependency-alignment}/mixed-dependencies/after/MODULE.bazel (100%) rename repo_policy_sync/policies/{bazel-dependency-dev-dependency-alignment => dev-dependency-alignment}/mixed-dependencies/before/MODULE.bazel (100%) rename repo_policy_sync/policies/{bazel-dependency-dev-dependency-alignment => dev-dependency-alignment}/no-target-dependencies/after/MODULE.bazel (100%) rename repo_policy_sync/policies/{bazel-dependency-dev-dependency-alignment => dev-dependency-alignment}/no-target-dependencies/before/MODULE.bazel (100%) rename repo_policy_sync/policies/{bazel-dependency-dev-dependency-alignment => dev-dependency-alignment}/policy.yml (100%) diff --git a/repo_policy_sync/policies/README.md b/repo_policy_sync/policies/README.md index 20eb40a..95e4707 100644 --- a/repo_policy_sync/policies/README.md +++ b/repo_policy_sync/policies/README.md @@ -28,7 +28,7 @@ use the [documentation index](../docs/README.md). | Policy | Responsibility | Typical lifecycle | | --- | --- | --- | -| `bazel-dependency-dev-dependency-alignment` | Keep selected Bazel toolchain and validation dependencies development-only while keeping `score_docs_as_code` in the normal dependency graph. | Baseline maintenance | +| `dev-dependency-alignment` | Keep selected Bazel toolchain and validation dependencies development-only while keeping `score_docs_as_code` in the normal dependency graph. | Baseline maintenance | | `docs-as-code-gitignore` | Update `score_docs_as_code` Git ignore entries and remove legacy configuration files. | One-time cleanup | | `minimal-bazel-module-declaration` | Keep `MODULE.bazel` limited to the repository-owned module name by removing version metadata. | One-time cleanup | | `minimum-bazel-version` | Upgrade repositories to at least Bazel `8.6.0` and regenerate the lockfile when required. | Baseline maintenance | diff --git a/repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/mixed-dependencies/after/MODULE.bazel b/repo_policy_sync/policies/dev-dependency-alignment/mixed-dependencies/after/MODULE.bazel similarity index 100% rename from repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/mixed-dependencies/after/MODULE.bazel rename to repo_policy_sync/policies/dev-dependency-alignment/mixed-dependencies/after/MODULE.bazel diff --git a/repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/mixed-dependencies/before/MODULE.bazel b/repo_policy_sync/policies/dev-dependency-alignment/mixed-dependencies/before/MODULE.bazel similarity index 100% rename from repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/mixed-dependencies/before/MODULE.bazel rename to repo_policy_sync/policies/dev-dependency-alignment/mixed-dependencies/before/MODULE.bazel diff --git a/repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/no-target-dependencies/after/MODULE.bazel b/repo_policy_sync/policies/dev-dependency-alignment/no-target-dependencies/after/MODULE.bazel similarity index 100% rename from repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/no-target-dependencies/after/MODULE.bazel rename to repo_policy_sync/policies/dev-dependency-alignment/no-target-dependencies/after/MODULE.bazel diff --git a/repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/no-target-dependencies/before/MODULE.bazel b/repo_policy_sync/policies/dev-dependency-alignment/no-target-dependencies/before/MODULE.bazel similarity index 100% rename from repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/no-target-dependencies/before/MODULE.bazel rename to repo_policy_sync/policies/dev-dependency-alignment/no-target-dependencies/before/MODULE.bazel diff --git a/repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/policy.yml b/repo_policy_sync/policies/dev-dependency-alignment/policy.yml similarity index 100% rename from repo_policy_sync/policies/bazel-dependency-dev-dependency-alignment/policy.yml rename to repo_policy_sync/policies/dev-dependency-alignment/policy.yml From 607252d910975dbb5424d844159b5bf17e190402 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Thu, 10 Sep 2026 21:18:17 +0200 Subject: [PATCH 6/7] fix(repo-policy-sync): add policy copyright headers --- .../mixed-dependencies/after/MODULE.bazel | 13 +++++++++++++ .../mixed-dependencies/before/MODULE.bazel | 13 +++++++++++++ .../no-target-dependencies/after/MODULE.bazel | 13 +++++++++++++ .../no-target-dependencies/before/MODULE.bazel | 13 +++++++++++++ .../policies/dev-dependency-alignment/policy.yml | 2 +- 5 files changed, 53 insertions(+), 1 deletion(-) diff --git a/repo_policy_sync/policies/dev-dependency-alignment/mixed-dependencies/after/MODULE.bazel b/repo_policy_sync/policies/dev-dependency-alignment/mixed-dependencies/after/MODULE.bazel index c997275..5522a89 100644 --- a/repo_policy_sync/policies/dev-dependency-alignment/mixed-dependencies/after/MODULE.bazel +++ b/repo_policy_sync/policies/dev-dependency-alignment/mixed-dependencies/after/MODULE.bazel @@ -1,3 +1,16 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + module(name = "example") bazel_dep(name = "score_bazel_cpp_toolchains", version = "1.0.0", dev_dependency = True) diff --git a/repo_policy_sync/policies/dev-dependency-alignment/mixed-dependencies/before/MODULE.bazel b/repo_policy_sync/policies/dev-dependency-alignment/mixed-dependencies/before/MODULE.bazel index 055aaeb..b7d713f 100644 --- a/repo_policy_sync/policies/dev-dependency-alignment/mixed-dependencies/before/MODULE.bazel +++ b/repo_policy_sync/policies/dev-dependency-alignment/mixed-dependencies/before/MODULE.bazel @@ -1,3 +1,16 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + module(name = "example") bazel_dep(name = "score_bazel_cpp_toolchains", version = "1.0.0") diff --git a/repo_policy_sync/policies/dev-dependency-alignment/no-target-dependencies/after/MODULE.bazel b/repo_policy_sync/policies/dev-dependency-alignment/no-target-dependencies/after/MODULE.bazel index da0cdfe..b3d7daa 100644 --- a/repo_policy_sync/policies/dev-dependency-alignment/no-target-dependencies/after/MODULE.bazel +++ b/repo_policy_sync/policies/dev-dependency-alignment/no-target-dependencies/after/MODULE.bazel @@ -1,3 +1,16 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + module(name = "example") bazel_dep(name = "unrelated_dependency", version = "1.0.0") diff --git a/repo_policy_sync/policies/dev-dependency-alignment/no-target-dependencies/before/MODULE.bazel b/repo_policy_sync/policies/dev-dependency-alignment/no-target-dependencies/before/MODULE.bazel index da0cdfe..b3d7daa 100644 --- a/repo_policy_sync/policies/dev-dependency-alignment/no-target-dependencies/before/MODULE.bazel +++ b/repo_policy_sync/policies/dev-dependency-alignment/no-target-dependencies/before/MODULE.bazel @@ -1,3 +1,16 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + module(name = "example") bazel_dep(name = "unrelated_dependency", version = "1.0.0") diff --git a/repo_policy_sync/policies/dev-dependency-alignment/policy.yml b/repo_policy_sync/policies/dev-dependency-alignment/policy.yml index 93fa527..d75f721 100644 --- a/repo_policy_sync/policies/dev-dependency-alignment/policy.yml +++ b/repo_policy_sync/policies/dev-dependency-alignment/policy.yml @@ -5,7 +5,7 @@ # information regarding copyright ownership. # # This program and the accompanying materials are made available under the -# terms of the Apache License 2.0 which is available at +# terms of the Apache License Version 2.0 which is available at # https://www.apache.org/licenses/LICENSE-2.0 # # SPDX-License-Identifier: Apache-2.0 From 7914dbfdfe02a40f33f6c526773c1e6d26c3c7c7 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Fri, 11 Sep 2026 11:59:06 +0200 Subject: [PATCH 7/7] fix(repo-policy-sync): handle commented Bazel arguments --- repo_policy_sync/src/bazel.py | 2 ++ repo_policy_sync/src/engine.py | 4 +-- .../src/operations/ensure_bazel_dependency.py | 13 +++++--- .../ensure_bazel_dependency_dev_dependency.py | 33 +++++++++++++------ ..._ensure_bazel_dependency_dev_dependency.py | 14 ++++++++ 5 files changed, 49 insertions(+), 17 deletions(-) diff --git a/repo_policy_sync/src/bazel.py b/repo_policy_sync/src/bazel.py index dac7e16..f3ae7f2 100644 --- a/repo_policy_sync/src/bazel.py +++ b/repo_policy_sync/src/bazel.py @@ -21,6 +21,8 @@ BazelVersion = tuple[int, int, int] +BAZEL_MODULE_NAME = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]*\Z") +BAZEL_NAME_ARGUMENT = re.compile(r"\bname\s*=\s*[\"']([^\"']+)[\"']") _VERSION = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z") _CONDITION = re.compile( r"\A\s*([A-Za-z0-9_][A-Za-z0-9_.-]*)\s*(==|!=|<=|>=|<|>)\s*" diff --git a/repo_policy_sync/src/engine.py b/repo_policy_sync/src/engine.py index 44ab6d5..8a7673d 100644 --- a/repo_policy_sync/src/engine.py +++ b/repo_policy_sync/src/engine.py @@ -23,6 +23,7 @@ from pathlib import Path from .bazel import ( + BAZEL_NAME_ARGUMENT, mask_starlark_comments, matches_bazel_dependency_condition, parse_bazel_version, @@ -35,7 +36,6 @@ from .operations._validation import validate_repository_path from .values import resolve_values, value_source_exists -_NAME_ARGUMENT = re.compile(r"\bname\s*=\s*[\"']([^\"']+)[\"']") _VERSION_ARGUMENT = re.compile(r"\bversion\s*=\s*[\"']([^\"']+)[\"']") _REDUCED_ENVIRONMENT_KEYS = { "CI", @@ -201,7 +201,7 @@ def _matches_bazel_condition(root: Path, policy: Policy) -> bool: dependencies: dict[str, tuple[int, int, int] | None] = {} for start, end in starlark_call_ranges(text, "bazel_dep"): body = mask_starlark_comments(text[start:end]) - name_match = _NAME_ARGUMENT.search(body) + name_match = BAZEL_NAME_ARGUMENT.search(body) if name_match is None: continue version_match = _VERSION_ARGUMENT.search(body) diff --git a/repo_policy_sync/src/operations/ensure_bazel_dependency.py b/repo_policy_sync/src/operations/ensure_bazel_dependency.py index 7fbe0dd..d7e4afe 100644 --- a/repo_policy_sync/src/operations/ensure_bazel_dependency.py +++ b/repo_policy_sync/src/operations/ensure_bazel_dependency.py @@ -20,7 +20,12 @@ from pathlib import Path from typing import Any -from ..bazel import mask_starlark_comments, starlark_call_ranges +from ..bazel import ( + BAZEL_MODULE_NAME, + BAZEL_NAME_ARGUMENT, + mask_starlark_comments, + starlark_call_ranges, +) from ..errors import PolicyError, RepoPolicySyncError from ..models import Change, EnsureBazelDependency, EnsureOperation, ValueReference from ._validation import ( @@ -32,8 +37,6 @@ ) _NUMERIC_VERSION = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z") -_MODULE_NAME = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]*\Z") -_NAME_ARGUMENT = re.compile(r"\bname\s*=\s*[\"']([^\"']+)[\"']") _VERSION_ARGUMENT = re.compile(r"\bversion\s*=\s*([\"'])([^\"']*)\1") @@ -53,7 +56,7 @@ def parse(self, raw: dict[str, Any], source: Path) -> EnsureBazelDependency: source, ) module_name = required_string(raw, "module_name", source) - if _MODULE_NAME.fullmatch(module_name) is None: + if BAZEL_MODULE_NAME.fullmatch(module_name) is None: raise PolicyError( f"policy {source}: module_name must be a valid Bazel module name" ) @@ -149,7 +152,7 @@ def _module_dependency( # A commented dependency is documentation, not an installed direct # dependency, so only ranges returned from active source are examined. body = mask_starlark_comments(text[start:end]) - name_matches = list(_NAME_ARGUMENT.finditer(body)) + name_matches = list(BAZEL_NAME_ARGUMENT.finditer(body)) if any( name_match.group(1) == operation.module_name for name_match in name_matches ): diff --git a/repo_policy_sync/src/operations/ensure_bazel_dependency_dev_dependency.py b/repo_policy_sync/src/operations/ensure_bazel_dependency_dev_dependency.py index c21fe92..b8f172d 100644 --- a/repo_policy_sync/src/operations/ensure_bazel_dependency_dev_dependency.py +++ b/repo_policy_sync/src/operations/ensure_bazel_dependency_dev_dependency.py @@ -20,7 +20,12 @@ from pathlib import Path from typing import Any -from ..bazel import mask_starlark_comments, starlark_call_ranges +from ..bazel import ( + BAZEL_MODULE_NAME, + BAZEL_NAME_ARGUMENT, + mask_starlark_comments, + starlark_call_ranges, +) from ..errors import PolicyError, RepoPolicySyncError from ..models import ( Change, @@ -38,8 +43,6 @@ # Keep this path internal to the operation so every policy can focus on the # dependency whose setting it governs instead of repeating an invariant path. _MODULE_FILE = Path("MODULE.bazel") -_MODULE_NAME = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]*\Z") -_NAME_ARGUMENT = re.compile(r"\bname\s*=\s*[\"']([^\"']+)[\"']") _DEV_DEPENDENCY_ARGUMENT = re.compile(r"\bdev_dependency\s*=\s*(True|False)\b") @@ -65,7 +68,7 @@ def parse( source, ) module_name = required_string(raw, "module_name", source) - if _MODULE_NAME.fullmatch(module_name) is None: + if BAZEL_MODULE_NAME.fullmatch(module_name) is None: raise PolicyError( f"policy {source}: module_name must be a valid Bazel module name" ) @@ -131,7 +134,7 @@ def _find_dependency( body = mask_starlark_comments(text[start:end]) name_matches = [ match - for match in _NAME_ARGUMENT.finditer(body) + for match in BAZEL_NAME_ARGUMENT.finditer(body) if match.group(1) == operation.module_name ] if not name_matches: @@ -193,14 +196,24 @@ def _set_dev_dependency(text: str, dependency: _DependencyCall) -> str: + text[dependency.body_end :] ) - content = body.rstrip(" \t\r\n") - trailing = body[len(content) :] + # Find the last active token so a separator is inserted before an inline + # comment rather than being swallowed by it. + masked_body = mask_starlark_comments(body) + active_end = len(masked_body.rstrip(" \t\r\n")) + content = body[:active_end] + trailing = body[active_end:] newline = "\r\n" if "\r\n" in trailing else "\n" - close_indent = trailing.rsplit("\n", 1)[-1] if "\n" in trailing else "" + if "\n" in trailing: + trailing_before_close, close_indent = trailing.rsplit("\n", 1) + trailing_before_close += "\n" + else: + trailing_before_close = "" + close_indent = "" argument_indent = _argument_indent(body) - separator = "" if content.endswith(",") else "," + active_content = masked_body[:active_end] + separator = "" if active_content.rstrip(" \t\r\n").endswith(",") else "," insertion = ( - f"{separator}{newline}{argument_indent}dev_dependency = True," + f"{separator}{trailing_before_close}{argument_indent}dev_dependency = True," f"{newline}{close_indent}" ) return ( diff --git a/repo_policy_sync/tests/operations/test_ensure_bazel_dependency_dev_dependency.py b/repo_policy_sync/tests/operations/test_ensure_bazel_dependency_dev_dependency.py index ae8b46d..8239724 100644 --- a/repo_policy_sync/tests/operations/test_ensure_bazel_dependency_dev_dependency.py +++ b/repo_policy_sync/tests/operations/test_ensure_bazel_dependency_dev_dependency.py @@ -59,6 +59,20 @@ def test_parse_rejects_non_boolean_dev_dependency(dev_dependency: object) -> Non version = "1.0.0", dev_dependency = True, ) +""", + True, + ), + ( + """bazel_dep( + name = "example_dependency", + version = "1.0.0" # pinned +) +""", + """bazel_dep( + name = "example_dependency", + version = "1.0.0", # pinned + dev_dependency = True, +) """, True, ),