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",