diff --git a/scripts/known_good/BUILD b/scripts/known_good/BUILD new file mode 100644 index 00000000000..11f18e00a6d --- /dev/null +++ b/scripts/known_good/BUILD @@ -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 Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +load("@rules_python//python:defs.bzl", "py_binary", "py_library") +load("@score_tooling//python_basics:defs.bzl", "score_py_pytest") + +# Library target: the known_good package (models + generators). +# Used as a dep by //scripts/tooling and by the resolve_deps binary below. +py_library( + name = "known_good", + srcs = glob( + ["**/*.py"], + exclude = ["tests/**"], + ), + visibility = ["//visibility:public"], +) + +# Tests for the known_good package (currently: ResolvedDependencies). +# Not part of //scripts/tooling:tooling_tests, whose glob is scoped to scripts/tooling/tests/. +score_py_pytest( + name = "known_good_tests", + srcs = glob(["tests/**/*.py"]), + data = ["//:known_good.json"], + pytest_config = "//:pyproject.toml", + deps = [":known_good"], +) + +# Runnable binary for the resolve + inject workflow. +# +# Stage 1 (export) — 'bazel mod graph' is a prerequisite; run it first and pass the result: +# bazel mod graph --output=json > graph.json +# bazel run //scripts/known_good:resolve_deps -- \ +# --mod-graph graph.json --export _resolved_deps/resolved_versions.json +# Writes the manifest and stores graph.json next to it; both are published as the +# stage1-resolved-deps artifact. Paths are resolved against BUILD_WORKSPACE_DIRECTORY, +# so graph.json does not need to be listed in data = [...]. +# +# Stage 2 (inject) — consumes that same directory: +# bazel run //scripts/known_good:resolve_deps -- \ +# _module/MODULE.bazel --resolved-deps _resolved_deps/ +# The manifest supplies each module's resolved version; graph.json identifies the +# module-under-test's transitive closure so all of it is pinned, not only direct deps. +py_binary( + name = "resolve_deps", + srcs = ["resolved_dependencies.py"], + main = "resolved_dependencies.py", + visibility = ["//visibility:public"], + deps = [":known_good"], +) diff --git a/scripts/known_good/resolved_dependencies.py b/scripts/known_good/resolved_dependencies.py new file mode 100644 index 00000000000..2483b959ca6 --- /dev/null +++ b/scripts/known_good/resolved_dependencies.py @@ -0,0 +1,794 @@ +#!/usr/bin/env python3 +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Resolved dependency versions from the reference_integration root. + +Provides :class:`ResolvedDependencies`, which holds the resolved version/commit per +dependency (sourced from ref_int's root — either ``known_good.json`` for local runs, +or the Stage-1 ``stage1-resolved-deps`` artifact for CI runs), and exposes an interface +to **scan** an individual module's ``MODULE.bazel`` and **overwrite** the declared +dependency versions to match the resolved set by appending the matching +``git_override`` / ``single_version_override`` directives. + +The injection operates on the CI checkout of the module — it is never committed back +to the module's released sources. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import re +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve().parent + + +def _repo_root() -> Path: + """ref_int's workspace root. + + Prefers the environment Bazel sets for ``bazel run`` targets so paths passed on the + command line resolve against the user's workspace rather than the runfiles tree (and + so ``graph.json`` need not be declared in ``data = [...]``). Falls back to walking up + from this file for direct ``python3 scripts/...`` invocations. + """ + for var in ("BUILD_WORKSPACE_DIRECTORY", "BUILD_WORKING_DIRECTORY"): + value = os.environ.get(var) + if value: + return Path(value) + return _HERE.parents[1] +try: + from known_good.models.known_good import load_known_good + from known_good.models.module import Module +except ImportError: + if str(_HERE) not in sys.path: + sys.path.insert(0, str(_HERE)) + from models.known_good import load_known_good # noqa: E402 + from models.module import Module # noqa: E402 + +# Marker delimiting the block we append, so injection is idempotent / detectable. +INJECTION_BEGIN = "# --- BEGIN ref_int resolved-deps injection ---" +INJECTION_END = "# --- END ref_int resolved-deps injection ---" + + +def generate_override_directive(module: Module, repo_commit_dict: dict[str, str] | None = None) -> str | None: + """Return the override directive (single_version_override / git_override) for a module. + + Returns just the override call without a preceding ``bazel_dep(...)`` line, so the + same logic can be reused both to build ref_int's score_modules_*.MODULE.bazel files + and to inject overrides into a module's own MODULE.bazel where bazel_dep is already + declared (see :meth:`ResolvedDependencies.overwrite`). + + Returns ``None`` when the module has neither a usable version nor a valid repo+commit. + """ + repo_commit_dict = repo_commit_dict or {} + commit = module.hash + + if module.repo in repo_commit_dict: + commit = repo_commit_dict[module.repo] + + patches_lines = "" + if module.bazel_patches: + patches_lines = " patches = [\n" + for patch in module.bazel_patches: + patches_lines += f' "{patch}",\n' + patches_lines += " ],\n" + patch_strip_line = " patch_strip = 1,\n" if patches_lines else "" + + if module.version: + return ( + "single_version_override(\n" + f' module_name = "{module.name}",\n' + f"{patch_strip_line}" + f"{patches_lines}" + f' version = "{module.version}",\n' + ")\n" + ) + + if not module.repo or not commit: + logging.warning( + "Skipping module %s with missing repo or commit: repo=%s, commit=%s", + module.name, + module.repo, + commit, + ) + return None + + if not re.match(r"^[a-fA-F0-9]{7,40}$", commit): + logging.warning("Skipping module %s with invalid commit hash: %s", module.name, commit) + return None + + return ( + "git_override(\n" + f' module_name = "{module.name}",\n' + f' commit = "{commit}",\n' + f"{patch_strip_line}" + f"{patches_lines}" + f' remote = "{module.repo}",\n' + ")\n" + ) + + +# The file Stage 1 stores alongside the manifest so Stage 2 can determine, for a given +# module, which *transitive* dependencies need an override (see DependencyGraph). +GRAPH_NAME = "graph.json" + + +class DependencyGraph: + """The ``bazel mod graph --output=json`` tree, queryable per module. + + Stage 2 needs more than the module-under-test's *declared* dependencies: Bazel only + honours ``*_override`` directives from the **root** module, so any transitive dep the + module does not itself declare falls through to plain MVS and can resolve to a version + ref_int never validated (e.g. ``score_communication`` never declares ``flatbuffers``; + it arrives via ``score_baselibs``). :meth:`closure` returns the full set so + :meth:`ResolvedDependencies.overwrite` can pin every one of them. + + The graph is *not* a plain tree. A module that appears more than once is emitted once + with its ``dependencies`` and thereafter as an ``unexpanded`` stub carrying no + children (in ref_int's graph: 865 unexpanded vs 157 expanded nodes). Walking the + subtree naively would therefore miss most of the closure, so nodes are indexed by name + on load and unexpanded references are resolved through that index. + """ + + def __init__(self, root: dict): + self._index: dict[str, dict] = {} + self._build_index(root) + + def _build_index(self, node: dict, seen: set[int] | None = None) -> None: + seen = set() if seen is None else seen + if id(node) in seen: + return + seen.add(id(node)) + name = node.get("name") + # Only expanded nodes carry children; the first occurrence is the authoritative one. + if name and not node.get("unexpanded") and "dependencies" in node: + self._index.setdefault(name, node) + for dep in node.get("dependencies") or []: + self._build_index(dep, seen) + + @classmethod + def from_file(cls, path: Path) -> DependencyGraph: + path = Path(path) + if not path.is_file(): + raise FileNotFoundError( + f"Dependency graph {path} not found. Stage 1 must produce it with " + f"'bazel mod graph --output=json' and store it as {GRAPH_NAME} in the " + f"stage1-resolved-deps artifact." + ) + return cls(json.loads(path.read_text())) + + @property + def names(self) -> set[str]: + return set(self._index) + + def closure(self, module_name: str) -> set[str]: + """Every module reachable from ``module_name``, excluding itself. + + Traversal follows ``dependencies`` and ``indirectDependencies``, resolving + ``unexpanded`` stubs via the name index. A ``visited`` set guards the ``cycles`` + the graph schema can carry. + """ + visited: set[str] = set() + stack = [module_name] + while stack: + node = self._index.get(stack.pop()) + if node is None: + continue # unexpanded-only or absent: nothing further to walk + for dep in node.get("dependencies") or []: + name = dep.get("name") + if name and name not in visited: + visited.add(name) + stack.append(name) + for dep in node.get("indirectDependencies") or []: + name = dep if isinstance(dep, str) else dep.get("name") + if name and name not in visited: + visited.add(name) + stack.append(name) + visited.discard(module_name) + return visited + + +# The single file that carries the resolved set from Stage 1 (resolve) to Stage 2 +# (per-module validation). It is the only handoff needed: first-party commits + +# third-party resolved versions, merged. The lock travels alongside only as evidence. +MANIFEST_NAME = "resolved_versions.json" + +# Built-in / non-registry modules that must not be given a single_version_override. +_SKIP_MODULES = {"bazel_tools"} + +# Capture the module name from any ``bazel_dep(name = "...")`` call (name is the first arg). +_BAZEL_DEP_RE = re.compile(r'bazel_dep\(\s*name\s*=\s*"([^"]+)"') +# The whole ``bazel_dep(...)`` argument list, so the dev_dependency flag can be read too. +# ``[^)]*`` is sufficient: bazel_dep takes only scalar keyword arguments, never a nested call. +_BAZEL_DEP_CALL_RE = re.compile(r"bazel_dep\((?P[^)]*)\)", re.S) +_DEV_DEPENDENCY_RE = re.compile(r"dev_dependency\s*=\s*True") +# Parsers for reconstructing the resolved set from generated score_modules_*.MODULE.bazel. +_GIT_OVERRIDE_BLOCK_RE = re.compile(r"git_override\((?P.*?)\)", re.S) +_SINGLE_VERSION_BLOCK_RE = re.compile(r"single_version_override\((?P.*?)\)", re.S) +# multiple_version_override pins several versions of one module simultaneously; unlike the +# other two it cannot be represented by a single Module.version, so it is carried +# separately (see ResolvedDependencies._multi). +_MULTIPLE_VERSION_BLOCK_RE = re.compile(r"multiple_version_override\((?P.*?)\)", re.S) +_VERSIONS_LIST_RE = re.compile(r"versions\s*=\s*\[(?P.*?)\]", re.S) +_FIELD_RE = lambda field: re.compile(rf'{field}\s*=\s*"([^"]+)"') # noqa: E731 + + +def _declared_deps(text: str) -> tuple[set[str], set[str]]: + """Return ``(every declared dep, those declared dev_dependency = True)``. + + bzlmod activates a ``dev_dependency`` edge only while the *declaring* module is the + root. ref_int is root in Stage 1 and the module under test is root in Stage 2, so a + module's dev dependencies are live in Stage 2 but structurally absent from the Stage-1 + graph the resolved set is built from. Telling the two apart is what lets + :meth:`ResolvedDependencies.overwrite` keep them out of the pin scope — see the + ``score_tooling`` case documented there. + """ + declared: set[str] = set() + dev: set[str] = set() + for call in _BAZEL_DEP_CALL_RE.finditer(text): + body = call.group("body") + name = _FIELD_RE("name").search(body) + if name is None: + continue + declared.add(name.group(1)) + if _DEV_DEPENDENCY_RE.search(body): + dev.add(name.group(1)) + return declared, dev + + +def _parse_versions_list(body: str) -> list[str]: + """Extract the string items of a ``versions = [...]`` keyword argument.""" + match = _VERSIONS_LIST_RE.search(body) + return re.findall(r'"([^"]+)"', match.group("items")) if match else [] + + +def generate_multiple_version_override(module_name: str, versions: list[str]) -> str: + """Return a ``multiple_version_override`` directive for a module pinned to several versions.""" + items = "".join(f' "{v}",\n' for v in versions) + return f'multiple_version_override(\n module_name = "{module_name}",\n versions = [\n{items} ],\n)\n' + + +def generate_bazel_dep(module: Module | None, name: str) -> str: + """Return the ``bazel_dep`` line that brings ``name`` into the root module's graph. + + An override is only legal for a module Bazel actually resolves; injecting one for a + transitive dependency the module-under-test does not declare would trip "the root + module specifies overrides on nonexistent module(s)". Declaring the ``bazel_dep`` + alongside the override is what makes it valid. + + The version is deliberate: a registry module repeats its *resolved* version so it + matches what MVS selects and ``--check_direct_dependencies`` stays quiet, while a + git-overridden module omits the version entirely (the override supplies the source, + and any literal here — ``0.0.0`` included — would only produce a spurious mismatch + warning). Omitting it is the same idiom the score modules already use, e.g. + ``bazel_dep(name = "score_tooling")``. + """ + if module is not None and module.version: + return f'bazel_dep(name = "{name}", version = "{module.version}")\n' + return f'bazel_dep(name = "{name}")\n' + + +class ResolvedDependencies: + """Resolved dependency versions from the reference_integration root. + + Holds a ``name -> Module`` map of the dependencies ref_int pins, and provides an + interface to scan + overwrite a module's ``MODULE.bazel`` to those versions. + """ + + def __init__(self, resolved: dict[str, Module], multi: dict[str, list[str]] | None = None): + self._resolved = resolved + # name -> versions, for modules ref_int pins with multiple_version_override. Kept + # apart from _resolved because a Module carries exactly one version. + self._multi = multi or {} + + # -- construction: "resolved deps versions from ref_int root" -------------------- + + @classmethod + def from_known_good(cls, known_good_path: Path) -> ResolvedDependencies: + """Build from ``known_good.json`` (local / dev source of the resolved pins).""" + kg = load_known_good(Path(known_good_path).resolve()) + resolved: dict[str, Module] = {} + for group in kg.modules.values(): + for module in group.values(): + resolved[module.name] = module + return cls(resolved) + + @classmethod + def from_resolved_artifact(cls, artifact_dir: Path) -> ResolvedDependencies: + """Build from the Stage-1 ``stage1-resolved-deps`` artifact. + + The handoff is the single ``resolved_versions.json`` manifest (see + :meth:`from_mod_graph` / :meth:`to_file`). For backward compatibility, if the + manifest is absent the older format is parsed: the generated + ``score_modules_*.MODULE.bazel`` override files, gated on the presence of + ``MODULE.bazel.lock`` as evidence of full resolution. + """ + artifact_dir = Path(artifact_dir) + + manifest = artifact_dir / MANIFEST_NAME + if manifest.is_file(): + return cls.from_file(manifest) + + # Legacy fallback: reconstruct from the generated override files. + lock = artifact_dir / "MODULE.bazel.lock" + if not lock.is_file(): + raise FileNotFoundError( + f"Neither {MANIFEST_NAME} nor MODULE.bazel.lock found in resolved-deps artifact " + f"{artifact_dir}; Stage 2 must consume the Stage-1 resolved dependency set." + ) + + module_files = sorted(artifact_dir.glob("score_modules_*.MODULE.bazel")) + if not module_files: + raise FileNotFoundError(f"No score_modules_*.MODULE.bazel files in resolved-deps artifact {artifact_dir}.") + + resolved: dict[str, Module] = {} + for mf in module_files: + for module in cls._parse_override_file(mf.read_text()): + resolved[module.name] = module + return cls(resolved) + + @classmethod + def from_mod_graph(cls, mod_graph_json: Path, override_files: list[Path]) -> ResolvedDependencies: + """Build the *complete* resolved set by merging two sources. + + * The override directives ref_int actually declares — parsed from its root + ``MODULE.bazel`` and the ``bazel_common/*.MODULE.bazel`` files it ``include()``s. + This carries every module ref_int pins by a non-registry source as its real + directive: ``git_override(commit, remote)`` for ``score_*`` plus third-party like + ``trlc`` / ``flatbuffers`` / ``rules_oci``, and ``single_version_override`` where + ref_int pins a registry version. The graph cannot supply these — it reports + overridden modules as version ``0.0.0``. + * ``bazel mod graph --output=json`` — the post-MVS resolved version of every other + (registry) module (protobuf, abseil, rules_rust, ...), emitted as + ``single_version_override`` so each module under test is forced to the exact + version ref_int resolved (MVS is graph-global, so a module's own subgraph could + otherwise select a different version). + + ``archive_override`` / ``local_path_override`` targets (e.g. ``rules_boost``) cannot + be represented and are logged as not carried. + """ + resolved: dict[str, Module] = {} + multi: dict[str, list[str]] = {} + unrepresentable: list[str] = [] + for f in override_files: + # Drop comment-only lines first: hand-written MODULE.bazel files contain + # commented-out overrides (e.g. "# git_override(... rules_rpm ...)") that must + # not be captured. Inline trailing comments (after a value) are left intact. + text = "\n".join(ln for ln in Path(f).read_text().splitlines() if not ln.lstrip().startswith("#")) + for module in cls._parse_override_file(text): # git_override + single_version_override + resolved[module.name] = module + for block in _MULTIPLE_VERSION_BLOCK_RE.finditer(text): + body = block.group("body") + name, versions = _field(body, "module_name"), _parse_versions_list(body) + if name and versions: + multi[name] = versions + for m in re.finditer(r'(archive_override|local_path_override)\(\s*module_name\s*=\s*"([^"]+)"', text): + unrepresentable.append(f"{m.group(2)} ({m.group(1)})") + + graph = json.loads(Path(mod_graph_json).read_text()) + versions: dict[str, str] = {} + _collect_resolved_versions(graph, versions) + skipped: list[str] = [] + for name, version in versions.items(): + if name in resolved or name in multi or name in _SKIP_MODULES: + continue # already carried by an override directive, or non-overridable + if not version or version == "0.0.0": + # Non-registry version: ref_int pins it via an override we did not capture + # (e.g. archive_override). single_version_override cannot reproduce it. + skipped.append(name) + continue + resolved[name] = Module(name=name, hash="", repo="", version=version) + + if unrepresentable: + logging.warning( + "Overrides not carried into manifest (need manual handling): %s", ", ".join(unrepresentable) + ) + if skipped: + logging.warning( + "Graph modules at version 0.0.0 with no carried override, skipped: %s", ", ".join(sorted(skipped)) + ) + return cls(resolved, multi) + + def to_file(self, path: Path) -> None: + """Serialize the resolved set to the JSON manifest (Stage 1 -> Stage 2 handoff). + + Only the fields needed to regenerate the override directive are stored + (``version`` for single_version_override; ``repo`` + ``hash`` for git_override). + Metadata is intentionally omitted — the manifest carries dependency pins, not the + module-under-test's test configuration (that comes from known_good.json). + """ + modules: dict[str, dict[str, object]] = {} + for name in sorted(self._resolved): + m = self._resolved[name] + entry: dict[str, object] = {"version": m.version} if m.version else {"repo": m.repo, "hash": m.hash} + if m.bazel_patches: + entry["bazel_patches"] = m.bazel_patches + modules[name] = entry + for name, versions in self._multi.items(): + modules[name] = {"versions": versions} + Path(path).write_text(json.dumps({"modules": dict(sorted(modules.items()))}, indent=2) + "\n") + + @classmethod + def from_file(cls, path: Path) -> ResolvedDependencies: + """Load a resolved set previously written by :meth:`to_file`.""" + data = json.loads(Path(path).read_text()) + entries = data.get("modules", {}) + multi = {name: md["versions"] for name, md in entries.items() if md.get("versions")} + resolved = {name: Module.from_dict(name, md) for name, md in entries.items() if name not in multi} + return cls(resolved, multi) + + @staticmethod + def _parse_override_file(text: str) -> list[Module]: + """Reconstruct Module objects from generated git/single_version override blocks.""" + modules: list[Module] = [] + + for match in _GIT_OVERRIDE_BLOCK_RE.finditer(text): + body = match.group("body") + name = _field(body, "module_name") + commit = _field(body, "commit") + remote = _field(body, "remote") + if name and commit and remote: + modules.append(Module(name=name, hash=commit, repo=remote)) + + for match in _SINGLE_VERSION_BLOCK_RE.finditer(text): + body = match.group("body") + name = _field(body, "module_name") + version = _field(body, "version") + if name and version: + modules.append(Module(name=name, hash="", repo="", version=version)) + + return modules + + # -- interface: scan + overwrite a module's MODULE.bazel ------------------------- + + @property + def names(self) -> set[str]: + return set(self._resolved) | set(self._multi) + + @property + def modules(self) -> dict[str, Module]: + return dict(self._resolved) + + @property + def multiple_versions(self) -> dict[str, list[str]]: + """Modules ref_int pins with ``multiple_version_override`` -> their versions.""" + return dict(self._multi) + + def get(self, name: str) -> Module | None: + return self._resolved.get(name) + + def scan(self, module_bazel: Path) -> list[str]: + """Return the names of dependencies a module declares via ``bazel_dep``.""" + text = Path(module_bazel).read_text() + # Ignore anything inside a previous injection block so re-scans are stable. + text = self._strip_injection(text) + return _BAZEL_DEP_RE.findall(text) + + def overwrite( + self, + module_bazel: Path, + *, + module_under_test: str | None = None, + write: bool = True, + graph: DependencyGraph | None = None, + ) -> str: + """Overwrite a module's dependency versions with ref_int's resolved set. + + Appends an override directive for every dependency in scope, so the module builds + and tests against exactly the versions ref_int resolved in Stage 1. + + Scope is the module's **transitive closure** when ``graph`` is supplied, not just + the dependencies it declares. Bazel honours ``*_override`` only from the root + module, so a transitive dependency the module does not itself declare would + otherwise fall through to plain MVS and can select a version ref_int never + validated (``score_communication`` never declares ``flatbuffers``; it arrives via + ``score_baselibs``). For each closure member that is not already declared, a + ``bazel_dep`` is emitted alongside the override — an override for a module absent + from the graph is rejected by Bazel as "the root module specifies overrides on + nonexistent module(s)", and the ``bazel_dep`` is what makes it legal. + + Without ``graph`` only declared dependencies are pinned, which leaves that + transitive gap open; Stage 2 always passes one. + + Scope covers the module's **public** dependency surface only. Dependencies the + module declares ``dev_dependency = True`` are excluded: bzlmod activates such an + edge only while the declaring module is root, so ref_int's Stage-1 graph never + resolved them and ref_int has no validated version to impose. This also matches + DR-008, which keeps quality tooling as a development dependency deliberately and + out of the released dependency footprint. A dev-declared dep that is *also* + reachable through the public closure stays pinned — it is then genuinely part of + the integration surface. + + * Skips the module under test itself (the root is never overridden). + * Always overwrites: any existing override the module already declares is replaced. + * A dependency with no entry in the resolved set is left to resolve on its own and + logged. With dev deps out of scope this now signals a real gap (e.g. a module + ref_int pins with an ``archive_override``) rather than the expected dev-dep case. + * Re-running is idempotent: a prior injection block is replaced. + """ + module_bazel = Path(module_bazel) + original = self._strip_injection(module_bazel.read_text()) + + declared, declared_dev = _declared_deps(original) + module_under_test = module_under_test or _module_name_of(original) + + from dataclasses import replace as _replace + + # Seed with the module's *public* declarations only. A dev-only dependency is + # deliberately left at whatever the module itself declares: ref_int never resolved + # it (Stage 1 runs with ref_int as root, where the edge is inactive), so it has no + # validated version to offer, and forcing one it did not resolve is unsound. + # Measured consequence of getting this wrong: score_lifecycle_health declares + # score_tooling dev-only at 1.2.0; pinning it to ref_int's commit pulled in + # score_tooling's own lobster/trlc deps, which are non-registry modules that only a + # root module can override, so the whole graph became unresolvable and Stage 2 ran + # zero tests. A dev dep that is *also* reached publicly stays in scope via the + # closure union below, which is correct: it is then part of the integration surface. + in_scope = declared - declared_dev + if graph is not None and module_under_test: + # The closure and nothing beyond it: pinning only what is already in this + # module's graph keeps the injection faithful to Stage 1 without pulling new + # modules (and the toolchains they register) into the build. Members with no + # resolved entry are reported below rather than filtered out silently. + in_scope |= graph.closure(module_under_test) + + directives: list[str] = [] + injected_names: list[str] = [] + unresolved: list[str] = [] + for name in sorted(in_scope): + if name == module_under_test or name in _SKIP_MODULES: + continue # the module under test is the root; never override it + if name in self._multi: + directive: str | None = generate_multiple_version_override(name, self._multi[name]) + module = None + else: + module = self._resolved.get(name) + if module is None: + unresolved.append(name) + continue + # Strip bazel_patches: they reference //patches/... labels in ref_int's + # workspace which do not exist inside another module's checkout. + module = _replace(module, bazel_patches=None) + directive = generate_override_directive(module) + if directive is None: + continue + # Only closure members the module does not declare need the bazel_dep line; + # emitting a second one for a declared dep would be a duplicate declaration. + if name not in declared: + directives.append(generate_bazel_dep(module, name)) + directives.append(directive) + injected_names.append(name) + + if unresolved: + logging.warning( + "%s: no entry in the resolved set for %s; leaving them to resolve on their own. " + "Expected for dev_dependency-only deps (active only when the module is root, " + "so absent from ref_int's Stage 1 graph) and for modules ref_int pins with an " + "archive_override/local_path_override.", + module_bazel, + ", ".join(unresolved), + ) + + # ref_int's injected override must be the ONLY override for each dep. A module that + # pins a dep with its own git_override/single_version_override (e.g. score_platform) + # would otherwise trip Bazel's "multiple overrides for dep found". Remove the + # module's own override for every dep we inject so ref_int's resolved version wins. + original = _strip_existing_overrides(original, injected_names) + + if not directives: + patched = original + else: + body = "\n".join(directives) + patched = f"{original.rstrip()}\n\n{INJECTION_BEGIN}\n{body}\n{INJECTION_END}\n" + + if write: + module_bazel.write_text(patched) + return patched + + @staticmethod + def _strip_injection(text: str) -> str: + """Remove a previously appended injection block, if present.""" + pattern = re.compile( + re.escape(INJECTION_BEGIN) + r".*?" + re.escape(INJECTION_END) + r"\n?", + re.S, + ) + return pattern.sub("", text).rstrip() + "\n" if pattern.search(text) else text + + +_OVERRIDE_KINDS = ( + "git_override", + "single_version_override", + "archive_override", + "local_path_override", + "multiple_version_override", +) + + +def _strip_existing_overrides(text: str, names: list[str]) -> str: + """Remove any ``*_override(module_name = "", ...)`` the module declares itself. + + ref_int re-injects its own resolved override for each of ``names``; Bazel forbids two + overrides for the same module, so a module's pre-existing override must be removed first. + Matches from the override call to its closing ``)`` on its own line. + """ + if not names: + return text + kinds = "|".join(_OVERRIDE_KINDS) + for name in names: + pattern = re.compile( + r"(?:" + kinds + r")\s*\(\s*module_name\s*=\s*\"" + re.escape(name) + r"\".*?\n\)\n?", + re.S, + ) + text = pattern.sub("", text) + return text.rstrip() + "\n" + + +def _field(body: str, field: str) -> str: + match = _FIELD_RE(field).search(body) + return match.group(1) if match else "" + + +# A module declares its own name in the module(...) call at the top of its MODULE.bazel. +_MODULE_DECL_RE = re.compile(r"module\(\s*name\s*=\s*\"([^\"]+)\"", re.S) + + +def injected_override_names(module_bazel_text: str) -> set[str]: + """Module names ref_int injected an override for, read back from a patched MODULE.bazel. + + The authoritative answer to "did ref_int pin this?" — used by the Stage 2 verification + to tell an override that failed to take effect (ref_int's bug) from a dependency that + was never pinned at all (the module resolved it on its own). + """ + if INJECTION_BEGIN not in module_bazel_text: + return set() + block = module_bazel_text.split(INJECTION_BEGIN, 1)[1].split(INJECTION_END, 1)[0] + return set(re.findall(r'_override\(\s*module_name\s*=\s*"([^"]+)"', block)) + + +def _module_name_of(module_bazel_text: str) -> str: + """The module's own name, from the ``module(name = "...")`` call in its MODULE.bazel. + + Lets Stage 2 identify the module under test from the file itself, so the caller need + not also pass ``--module-under-test``. + """ + match = _MODULE_DECL_RE.search(module_bazel_text) + return match.group(1) if match else "" + + +def _collect_resolved_versions(node: dict, acc: dict[str, str]) -> None: + """Walk a ``bazel mod graph --output=json`` tree, recording name -> resolved version. + + Each node carries the post-MVS ``name`` and ``version``; a module can appear many + times in the graph but always at the single resolved version, so deduping by name is + safe. The ```` node has an empty version and is skipped implicitly. + """ + for dep in node.get("dependencies", []): + name, version = dep.get("name"), dep.get("version") + if name and version: + acc[name] = version + _collect_resolved_versions(dep, acc) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Resolve (Stage 1) or inject (Stage 2) ref_int's resolved dependency set (DR-008 Option 4)." + ) + parser.add_argument( + "module_bazel", + type=Path, + nargs="?", + default=None, + help="Inject mode: path to the module's MODULE.bazel to overwrite. Omit when using --export.", + ) + parser.add_argument( + "--known-good-path", + type=Path, + default=None, + help="Export mode only: known_good.json (defaults to ref_int's). Not a valid inject source.", + ) + parser.add_argument( + "--resolved-deps", + type=Path, + default=None, + help=( + "Inject mode (required): Stage-1 stage1-resolved-deps artifact dir, holding " + f"{MANIFEST_NAME} and {GRAPH_NAME}." + ), + ) + parser.add_argument( + "--mod-graph", + type=Path, + default=None, + help="Export mode: 'bazel mod graph --output=json' output, merged with known_good.json.", + ) + parser.add_argument( + "--export", + type=Path, + default=None, + help=f"Export mode: write the merged resolved set to this {MANIFEST_NAME} manifest and exit.", + ) + parser.add_argument( + "--module-under-test", + default=None, + help="Name of the module under test (never overridden as it is the root).", + ) + parser.add_argument("--dry-run", action="store_true", help="Print patched content instead of writing.") + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + + # Export mode (Stage 1): build the manifest by merging the override directives ref_int + # declares (root MODULE.bazel + bazel_common/*.MODULE.bazel) with the resolved registry + # versions from 'bazel mod graph'. + if args.export is not None: + if args.mod_graph is None: + raise SystemExit("--export requires --mod-graph (output of 'bazel mod graph --output=json')") + mod_graph = Path(args.mod_graph) + if not mod_graph.is_file(): + raise SystemExit( + f"--mod-graph {mod_graph} does not exist. Produce it first with: " + "bazel mod graph --output=json > graph.json" + ) + repo_root = _repo_root() + override_files = [ + f + for f in [repo_root / "MODULE.bazel", *sorted((repo_root / "bazel_common").glob("*.MODULE.bazel"))] + if f.is_file() + ] + resolved = ResolvedDependencies.from_mod_graph(mod_graph, override_files) + export = Path(args.export) + export.parent.mkdir(parents=True, exist_ok=True) + resolved.to_file(export) + # Stage 2 needs the graph too: the manifest says which version each module resolves + # to, the graph says which of them a given module actually depends on. + graph_copy = export.parent / GRAPH_NAME + graph_copy.write_text(mod_graph.read_text()) + print(f"Wrote resolved dependency manifest ({len(resolved.names)} modules) to {export}") + print(f"Stored dependency graph for Stage 2 at {graph_copy}") + return + + # Inject mode (Stage 2): overwrite a module's MODULE.bazel with the resolved set. + if args.module_bazel is None: + raise SystemExit("module_bazel is required unless --export is given") + + # known_good.json is not a valid inject source: it carries only first-party score + # modules with no transitive registry versions, so the closure could not be pinned. + if not args.resolved_deps: + raise SystemExit( + "--resolved-deps is required for inject mode: Stage 2 must pin against the " + "Stage-1 resolved set. known_good.json carries only first-party pins and no " + "transitive versions, so it cannot back the injection." + ) + resolved = ResolvedDependencies.from_resolved_artifact(args.resolved_deps) + graph = DependencyGraph.from_file(Path(args.resolved_deps) / GRAPH_NAME) + + patched = resolved.overwrite( + args.module_bazel, + module_under_test=args.module_under_test, + write=not args.dry_run, + graph=graph, + ) + if args.dry_run: + print(patched) + else: + print(f"Injected resolved-deps overrides into {args.module_bazel}") + + +if __name__ == "__main__": + main() diff --git a/scripts/known_good/tests/test_resolved_dependencies.py b/scripts/known_good/tests/test_resolved_dependencies.py new file mode 100644 index 00000000000..18c7d30c037 --- /dev/null +++ b/scripts/known_good/tests/test_resolved_dependencies.py @@ -0,0 +1,493 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Unit tests for ResolvedDependencies (DR-008 Option 4 dependency injection). + +Self-contained: builds the resolved set from a temporary known_good.json and +overwrites a temporary module MODULE.bazel — no cloned repos or Bazel required. +""" + +import json +import logging +import sys +from pathlib import Path + +import pytest + +# Make scripts/ importable so known_good.* package resolves when run via plain pytest. +_SCRIPTS_DIR = Path(__file__).resolve().parents[2] +if str(_SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPTS_DIR)) + +from known_good.resolved_dependencies import ( # noqa: E402 + INJECTION_BEGIN, + INJECTION_END, + DependencyGraph, + ResolvedDependencies, + generate_override_directive, +) + +KNOWN_GOOD = { + "modules": { + "target_sw": { + "score_baselibs": { + "repo": "https://github.com/eclipse-score/baselibs.git", + "hash": "cab36dd7de92aaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bazel_patches": ["patches/baselibs/001-fix.patch"], + }, + "score_logging": { + "repo": "https://github.com/eclipse-score/logging.git", + "hash": "0e9187f79a99bbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, + "score_persistency": { + "repo": "https://github.com/eclipse-score/persistency.git", + "hash": "4d1fa1ae3c55cccccccccccccccccccccccccccc", + }, + }, + "tooling": { + "score_tooling": { + "repo": "https://github.com/eclipse-score/tooling.git", + "version": "1.2.0", + }, + }, + }, + "timestamp": "2026-01-01T00:00:00+00:00Z", +} + +MODULE_BAZEL = """\ +module(name = "score_persistency", version = "0.0.0") + +bazel_dep(name = "rules_cc", version = "0.2.17") +bazel_dep(name = "score_baselibs", version = "0.2.7") +bazel_dep(name = "score_logging", version = "0.2.0") +bazel_dep(name = "score_tooling", version = "1.0.0") +bazel_dep(name = "score_unpinned", version = "9.9.9") +""" + + +@pytest.fixture +def known_good_file(tmp_path: Path) -> Path: + p = tmp_path / "known_good.json" + p.write_text(json.dumps(KNOWN_GOOD)) + return p + + +@pytest.fixture +def module_bazel(tmp_path: Path) -> Path: + p = tmp_path / "MODULE.bazel" + p.write_text(MODULE_BAZEL) + return p + + +@pytest.fixture +def resolved(known_good_file: Path) -> ResolvedDependencies: + return ResolvedDependencies.from_known_good(known_good_file) + + +def _node(name: str, version: str = "1.0", deps: list[dict] | None = None, **extra) -> dict: + """An expanded graph node, matching 'bazel mod graph --output=json'.""" + return {"name": name, "version": version, "dependencies": deps or [], "indirectDependencies": [], **extra} + + +def _unexpanded(name: str, version: str = "1.0") -> dict: + """A repeated reference: no 'dependencies' key, so it must be resolved via the index.""" + return {"name": name, "version": version, "unexpanded": True} + + +class TestDependencyGraph: + """Closure computation over the mod graph, including its unexpanded-node encoding.""" + + def test_closure_follows_transitive_edges(self): + baselibs = _node("score_baselibs", deps=[_node("flatbuffers")]) + graph = DependencyGraph(_node("", "", [_node("score_persistency", deps=[baselibs])])) + assert graph.closure("score_persistency") == {"score_baselibs", "flatbuffers"} + + def test_closure_resolves_unexpanded_references(self): + # Bazel emits a module's children only at its first occurrence; every later + # occurrence is an 'unexpanded' stub. Walking the subtree literally would stop at + # the stub and miss flatbuffers, which is exactly the gap this must not have. + graph = DependencyGraph( + _node( + "", + "", + [ + _node("score_baselibs", deps=[_node("flatbuffers")]), + _node("score_communication", deps=[_unexpanded("score_baselibs")]), + ], + ) + ) + assert graph.closure("score_communication") == {"score_baselibs", "flatbuffers"} + + def test_closure_excludes_the_module_itself(self): + graph = DependencyGraph(_node("", "", [_node("score_time", deps=[_node("rules_cc")])])) + assert "score_time" not in graph.closure("score_time") + + def test_closure_terminates_on_cycles(self): + a = _node("a") + b = _node("b", deps=[_unexpanded("a")]) + a["dependencies"] = [b] + assert DependencyGraph(_node("", "", [a])).closure("a") == {"b"} + + def test_closure_of_unknown_module_is_empty(self): + graph = DependencyGraph(_node("", "", [_node("score_time")])) + assert graph.closure("not_in_graph") == set() + + def test_from_file_reports_a_missing_graph(self, tmp_path: Path): + with pytest.raises(FileNotFoundError, match="bazel mod graph"): + DependencyGraph.from_file(tmp_path / "graph.json") + + +class TestFromKnownGood: + def test_names_span_all_groups(self, resolved: ResolvedDependencies): + assert {"score_baselibs", "score_logging", "score_persistency", "score_tooling"} <= resolved.names + + def test_get_returns_resolved_commit(self, resolved: ResolvedDependencies): + assert resolved.get("score_baselibs").hash.startswith("cab36dd7de92") + + def test_version_module_kept(self, resolved: ResolvedDependencies): + assert resolved.get("score_tooling").version == "1.2.0" + + +class TestScan: + def test_returns_declared_deps(self, resolved: ResolvedDependencies, module_bazel: Path): + declared = resolved.scan(module_bazel) + assert "score_baselibs" in declared + assert "score_unpinned" in declared + assert "rules_cc" in declared + + +class TestOverwrite: + def test_pins_declared_resolved_siblings(self, resolved: ResolvedDependencies, module_bazel: Path): + patched = resolved.overwrite(module_bazel, module_under_test="score_persistency", write=False) + block = patched.split(INJECTION_BEGIN)[1].split(INJECTION_END)[0] + assert 'git_override(\n module_name = "score_baselibs"' in block + assert 'commit = "cab36dd7de92aaaaaaaaaaaaaaaaaaaaaaaaaaaa"' in block + # version module -> single_version_override + assert 'single_version_override(\n module_name = "score_tooling"' in block + assert 'version = "1.2.0"' in block + + def test_strips_patches(self, resolved: ResolvedDependencies, module_bazel: Path): + # bazel_patches reference //patches/... labels that exist only in ref_int's + # workspace, so they are stripped from the injected overrides. + patched = resolved.overwrite(module_bazel, module_under_test="score_persistency", write=False) + assert "patches/baselibs/001-fix.patch" not in patched + assert "patch_strip" not in patched + + def test_skips_resolved_dep_not_declared(self, resolved: ResolvedDependencies, tmp_path: Path): + # Only declared deps are injected. Overriding a module that is NOT in the module's + # dependency graph makes Bazel fail ("overrides on nonexistent module(s)"), so a + # resolved dep the module does not declare must NOT be injected. + mod = tmp_path / "MODULE.bazel" + mod.write_text( + 'module(name = "score_persistency", version = "0.0.0")\n' + 'bazel_dep(name = "score_baselibs", version = "0.1")\n' + ) + block = resolved.overwrite(mod, module_under_test="score_persistency", write=False).split(INJECTION_BEGIN)[1] + assert 'module_name = "score_baselibs"' in block # declared -> injected + assert 'module_name = "score_logging"' not in block # not declared -> not injected + + def test_skips_root_module(self, resolved: ResolvedDependencies, module_bazel: Path): + patched = resolved.overwrite(module_bazel, module_under_test="score_persistency", write=False) + block = patched.split(INJECTION_BEGIN)[1].split(INJECTION_END)[0] + assert 'module_name = "score_persistency"' not in block + + def test_skips_unpinned_third_party(self, resolved: ResolvedDependencies, module_bazel: Path): + patched = resolved.overwrite(module_bazel, module_under_test="score_persistency", write=False) + block = patched.split(INJECTION_BEGIN)[1].split(INJECTION_END)[0] + assert "score_unpinned" not in block + assert "rules_cc" not in block + + def test_idempotent(self, resolved: ResolvedDependencies, module_bazel: Path): + first = resolved.overwrite(module_bazel, module_under_test="score_persistency", write=True) + second = resolved.overwrite(module_bazel, module_under_test="score_persistency", write=True) + assert first == second + assert second.count(INJECTION_BEGIN) == 1 + + def test_warns_on_declared_dep_not_in_resolved_set( + self, resolved: ResolvedDependencies, module_bazel: Path, caplog: pytest.LogCaptureFixture + ): + # "score_unpinned" is declared in MODULE_BAZEL but has no known_good.json entry. + # This is expected to be effectively impossible once the resolved set is sourced + # from the full 'bazel mod graph' (a superset of any module's own graph), so it + # must be surfaced as a warning rather than silently ignored. + with caplog.at_level(logging.WARNING): + resolved.overwrite(module_bazel, module_under_test="score_persistency", write=False) + assert "score_unpinned" in caplog.text + + def test_overwrites_dep_with_existing_override(self, resolved: ResolvedDependencies, tmp_path: Path): + # ref_int always decides the version — a pre-existing override in the module is replaced. + mod = tmp_path / "MODULE.bazel" + mod.write_text( + MODULE_BAZEL + '\ngit_override(\n module_name = "score_logging",\n commit = "deadbeef",\n' + ' remote = "https://example.com/x.git",\n)\n' + ) + patched = resolved.overwrite(mod, module_under_test="score_persistency", write=False) + block = patched.split(INJECTION_BEGIN)[1].split(INJECTION_END)[0] + # ref_int's resolved commit must appear in the injection block, overwriting "deadbeef" + assert 'module_name = "score_logging"' in block + assert "deadbeef" not in block + # the module's OWN override must be removed from the whole file — otherwise Bazel + # aborts with "multiple overrides for dep score_logging found". + assert "deadbeef" not in patched + assert patched.count('module_name = "score_logging"') == 1 + + +class TestOverwriteTransitive: + """Closure injection: pin transitive deps the module never declares itself.""" + + @staticmethod + def _graph() -> DependencyGraph: + # score_persistency -> score_baselibs -> score_logging. Only score_baselibs is + # declared directly by the module; score_logging arrives through it. + return DependencyGraph( + _node( + "", + "", + [_node("score_persistency", deps=[_node("score_baselibs", deps=[_node("score_logging")])])], + ) + ) + + @pytest.fixture + def only_baselibs(self, tmp_path: Path) -> Path: + p = tmp_path / "MODULE.bazel" + p.write_text('module(name = "score_persistency", version = "0.0.0")\nbazel_dep(name = "score_baselibs")\n') + return p + + def test_injects_transitive_dep_with_its_bazel_dep(self, resolved: ResolvedDependencies, only_baselibs: Path): + patched = resolved.overwrite( + only_baselibs, module_under_test="score_persistency", write=False, graph=self._graph() + ) + block = patched.split(INJECTION_BEGIN)[1].split(INJECTION_END)[0] + assert 'module_name = "score_baselibs"' in block + assert 'module_name = "score_logging"' in block + # The override alone would be rejected ("overrides on nonexistent module(s)") since + # the module never declares score_logging — the bazel_dep is what makes it legal. + assert 'bazel_dep(name = "score_logging")' in block + + def test_declared_dep_gets_no_extra_bazel_dep(self, resolved: ResolvedDependencies, only_baselibs: Path): + # score_baselibs is already declared above the block; re-declaring it would be a + # duplicate declaration of the same module. + patched = resolved.overwrite( + only_baselibs, module_under_test="score_persistency", write=False, graph=self._graph() + ) + block = patched.split(INJECTION_BEGIN)[1].split(INJECTION_END)[0] + assert "bazel_dep" not in block.split('module_name = "score_baselibs"')[0] + assert patched.count('bazel_dep(name = "score_baselibs")') == 1 + + def test_registry_dep_stub_repeats_the_resolved_version(self, resolved: ResolvedDependencies, tmp_path: Path): + # score_tooling is registry-pinned (version 1.2.0). Its stub must carry that exact + # version so it matches MVS and --check_direct_dependencies stays quiet; a + # git-overridden module instead gets a bare bazel_dep with no version at all. + mod = tmp_path / "MODULE.bazel" + mod.write_text('module(name = "score_persistency", version = "0.0.0")\n') + graph = DependencyGraph( + _node("", "", [_node("score_persistency", deps=[_node("score_tooling"), _node("score_logging")])]) + ) + block = ( + resolved.overwrite(mod, module_under_test="score_persistency", write=False, graph=graph) + .split(INJECTION_BEGIN)[1] + .split(INJECTION_END)[0] + ) + assert 'bazel_dep(name = "score_tooling", version = "1.2.0")' in block + assert 'bazel_dep(name = "score_logging")\n' in block + + def test_without_graph_only_declared_deps_are_pinned(self, resolved: ResolvedDependencies, only_baselibs: Path): + patched = resolved.overwrite(only_baselibs, module_under_test="score_persistency", write=False) + assert 'module_name = "score_logging"' not in patched + + def test_closure_member_absent_from_resolved_set_is_skipped( + self, resolved: ResolvedDependencies, only_baselibs: Path, caplog: pytest.LogCaptureFixture + ): + # A module's dev_dependency deps activate only when it is root — true in Stage 2 but + # not in Stage 1 — so ref_int's graph never saw them. Warn, never fail. + graph = DependencyGraph( + _node("", "", [_node("score_persistency", deps=[_node("rules_doxygen")])]), + ) + with caplog.at_level(logging.WARNING): + patched = resolved.overwrite( + only_baselibs, module_under_test="score_persistency", write=False, graph=graph + ) + assert "rules_doxygen" not in patched + assert "rules_doxygen" in caplog.text + + def test_module_under_test_inferred_from_module_declaration( + self, resolved: ResolvedDependencies, only_baselibs: Path + ): + # module(name = "...") identifies the root, so --module-under-test is optional. + patched = resolved.overwrite(only_baselibs, write=False, graph=self._graph()) + assert 'module_name = "score_persistency"' not in patched + assert 'module_name = "score_baselibs"' in patched + + +class TestDevDependencyScope: + """Dev-only deps stay out of the pin scope. + + Regression cover for the ``score_lifecycle_health`` Stage-2 failure: it declares + ``score_tooling`` ``dev_dependency = True``, so bzlmod leaves that edge inactive while + ref_int is root and the Stage-1 graph never resolved past it. Pinning it anyway forced + ref_int's ``score_tooling`` commit, which pulls in ``lobster``/``trlc`` — non-registry + modules only a root module can override — and the whole graph became unresolvable + (``module lobster@0.0.0 not found in registries``), so Stage 2 ran zero tests. + """ + + @pytest.fixture + def dev_and_public(self, tmp_path: Path) -> Path: + p = tmp_path / "MODULE.bazel" + p.write_text( + 'module(name = "score_persistency", version = "0.0.0")\n' + 'bazel_dep(name = "score_baselibs", version = "0.2.7")\n' + 'bazel_dep(name = "score_tooling", version = "1.0.0", dev_dependency = True)\n' + ) + return p + + def test_dev_only_dep_is_not_pinned(self, resolved: ResolvedDependencies, dev_and_public: Path): + patched = resolved.overwrite(dev_and_public, module_under_test="score_persistency", write=False) + assert 'module_name = "score_tooling"' not in patched + + def test_public_dep_is_still_pinned(self, resolved: ResolvedDependencies, dev_and_public: Path): + # The integration contract must survive the narrowing: dropping dev deps must not + # quietly stop pinning the public surface, or Stage 2 goes vacuously green. + patched = resolved.overwrite(dev_and_public, module_under_test="score_persistency", write=False) + assert 'module_name = "score_baselibs"' in patched + + def test_dev_dep_reachable_publicly_is_still_pinned(self, resolved: ResolvedDependencies, dev_and_public: Path): + # Declared dev *and* reached through the public closure => genuinely part of the + # integration surface, so it stays pinned. The exclusion is about the edge ref_int + # never resolved, not about the module's name. + graph = DependencyGraph( + _node( + "", + "", + [_node("score_persistency", deps=[_node("score_baselibs", deps=[_node("score_tooling")])])], + ) + ) + patched = resolved.overwrite(dev_and_public, module_under_test="score_persistency", write=False, graph=graph) + assert 'module_name = "score_tooling"' in patched + + def test_multiline_dev_dependency_is_detected(self, resolved: ResolvedDependencies, tmp_path: Path): + # bazel_dep is commonly written across several lines; the flag must still be seen. + p = tmp_path / "MODULE.bazel" + p.write_text( + 'module(name = "score_persistency", version = "0.0.0")\n' + "bazel_dep(\n" + ' name = "score_tooling",\n' + ' version = "1.0.0",\n' + " dev_dependency = True,\n" + ")\n" + ) + assert 'module_name = "score_tooling"' not in resolved.overwrite( + p, module_under_test="score_persistency", write=False + ) + + +class TestFromModGraph: + @staticmethod + def _graph() -> dict: + # Mirrors 'bazel mod graph --output=json': overridden modules report version 0.0.0. + return { + "key": "", + "name": "ref_int", + "version": "", + "dependencies": [ + {"name": "trlc", "version": "0.0.0"}, # git_override (carried from file) + {"name": "rules_boost", "version": "0.0.0"}, # archive_override (not representable) + {"name": "score_baselibs", "version": "0.0.0"}, # git_override (carried from file) + { + "name": "protobuf", + "version": "29.1", + "dependencies": [ + {"name": "abseil-cpp", "version": "20250512.1"}, + ], + }, + ], + } + + def test_merges_overrides_and_registry_versions(self, tmp_path: Path): + graph = tmp_path / "graph.json" + graph.write_text(json.dumps(self._graph())) + root = tmp_path / "MODULE.bazel" + root.write_text( + 'git_override(\n module_name = "trlc",\n commit = "abc1234",\n' + ' remote = "https://github.com/x/trlc.git",\n)\n' + 'archive_override(\n module_name = "rules_boost",\n urls = ["https://e/x.tar"],\n)\n' + ) + scoremods = tmp_path / "score_modules_target_sw.MODULE.bazel" + scoremods.write_text( + 'git_override(\n module_name = "score_baselibs",\n commit = "def5678",\n' + ' remote = "https://github.com/eclipse-score/baselibs.git",\n)\n' + ) + + rd = ResolvedDependencies.from_mod_graph(graph, [root, scoremods]) + # Overridden modules carried as their real git_override (graph's 0.0.0 ignored). + assert rd.get("trlc").hash == "abc1234" + assert rd.get("score_baselibs").hash == "def5678" + # Registry modules carried from the resolved graph version. + assert rd.get("protobuf").version == "29.1" + assert rd.get("abseil-cpp").version == "20250512.1" + # archive_override target at 0.0.0 is not representable -> not carried. + assert rd.get("rules_boost") is None + + def test_ignores_commented_out_overrides(self, tmp_path: Path): + graph = tmp_path / "graph.json" + graph.write_text(json.dumps({"key": "", "name": "r", "version": "", "dependencies": []})) + root = tmp_path / "MODULE.bazel" + root.write_text( + '# git_override(\n# module_name = "rules_rpm",\n' + '# commit = "a78e559cf81754c199c926229dc6b4443e1ff149",\n' + '# remote = "https://github.com/eclipse-score/inc_os_autosd.git",\n# )\n' + ) + rd = ResolvedDependencies.from_mod_graph(graph, [root]) + assert rd.get("rules_rpm") is None # commented-out override must not be carried + + +class TestManifestRoundtrip: + def test_to_file_is_lean_and_roundtrips(self, tmp_path: Path, resolved: ResolvedDependencies): + manifest = tmp_path / "resolved_versions.json" + resolved.to_file(manifest) + data = json.loads(manifest.read_text())["modules"] + assert "metadata" not in data["score_baselibs"] # lean: no test-config noise + assert data["score_tooling"] == {"version": "1.2.0"} + loaded = ResolvedDependencies.from_file(manifest) + assert loaded.get("score_baselibs").hash == resolved.get("score_baselibs").hash + assert loaded.get("score_tooling").version == "1.2.0" + + +class TestFromResolvedArtifact: + def test_prefers_manifest(self, tmp_path: Path, resolved: ResolvedDependencies): + art = tmp_path / "art" + art.mkdir() + resolved.to_file(art / "resolved_versions.json") + # With the manifest present, no lock / score_modules files are required. + parsed = ResolvedDependencies.from_resolved_artifact(art) + assert parsed.get("score_baselibs").hash == resolved.get("score_baselibs").hash + assert parsed.get("score_tooling").version == "1.2.0" + + def test_requires_manifest_or_lockfile(self, tmp_path: Path): + (tmp_path / "score_modules_target_sw.MODULE.bazel").write_text("bazel_dep(name='x')\n") + with pytest.raises(FileNotFoundError): + ResolvedDependencies.from_resolved_artifact(tmp_path) + + def test_roundtrip_known_good_to_artifact(self, tmp_path: Path, resolved: ResolvedDependencies): + # Build an artifact dir mirroring stage1-resolved-deps (legacy format), then parse it back. + art = tmp_path / "art" + art.mkdir() + (art / "MODULE.bazel.lock").write_text("{}") + blocks = [] + for m in resolved.modules.values(): + directive = generate_override_directive(m) + if directive: + blocks.append(f'bazel_dep(name = "{m.name}")\n' + directive) + (art / "score_modules_target_sw.MODULE.bazel").write_text("\n".join(blocks)) + + parsed = ResolvedDependencies.from_resolved_artifact(art) + assert parsed.get("score_baselibs").hash == resolved.get("score_baselibs").hash + assert parsed.get("score_tooling").version == "1.2.0" diff --git a/scripts/tooling/BUILD b/scripts/tooling/BUILD index c2088414897..d989b765db4 100644 --- a/scripts/tooling/BUILD +++ b/scripts/tooling/BUILD @@ -81,6 +81,13 @@ py_binary( visibility = ["//visibility:public"], ) +# Alias: expose resolve_deps under //scripts/tooling for `bazel run //scripts/tooling:resolve_deps`. +alias( + name = "resolve_deps", + actual = "//scripts/known_good:resolve_deps", + visibility = ["//visibility:public"], +) + # Tests target score_py_pytest( name = "tooling_tests",