4"""Gate: assert every pinned host tool resolves to its project-pinned version.
8The self-hosted runner and the dev box resolve tools through PATH, and PATH
9differs between a login shell and a non-interactive one. Measured on the dev
10box, ``ssh dev '<cmd>'`` and ``ssh dev 'bash -lc "<cmd>"'`` resolved DIFFERENT
11binaries: shellcheck 0.9.0 vs 0.11.0, shfmt 3.6.0 vs 3.13.1, ruff absent vs
120.15.19. A gate run through the wrong PATH produces findings CI never
13reproduces, or -- worse -- misses findings CI has. ``use_pinned_tool_path`` in
14scripts/ci.sh makes the resolution deterministic; this check makes the WRONG
15version FAIL LOUD rather than pass quietly, the same class of hole as
16check_annotations.py exiting 0 without libclang.
19-----------------------
20The pinned versions are not restated here. Native toolchain pins are parsed
21from ``.devcontainer/Dockerfile``; Python tool pins come from the exact direct
22dependencies in ``pyproject.toml`` and their transitive closure is committed in
23``uv.lock``. Reading each owning source keeps native and container checks equal.
27* ``exact`` -- version string must equal the pin (just, ruff, shellcheck, shfmt,
28 cppcheck, cmakelang, yamllint, actionlint, hadolint, gcovr,
29 doxygen). These are the tools whose findings drift with the
30 exact version. gcovr is exact because 8.4 changed its data
31 model to retain multiple coverage records per source line,
32 which changes this tree's per-file line and branch counts.
33* ``major`` -- major must equal the pin (clang-format-22, clang-tidy-18,
34 gcc-14). The clang family and the gcc-14 host-tool arm
35 (#356) are pinned by major on purpose; the tree is
36 formatted/linted/built to that major and the binary carries
40``--selftest`` builds fake tools that report chosen versions, then asserts the
41comparator returns the right verdict for a match AND a mismatch in every mode,
42plus a missing tool. Sabotaging the comparator (making it always pass) turns
43the selftest red instead of letting a broken check report success forever.
45It also asserts the one spec that is not unconditional. doxygen is pinned only
46where the Dockerfile installs the pinned release, so a mistake in that condition
47could silently drop the tool from the registry -- and a pin nobody compares is
48exactly how the deployed image sat on doxygen 1.9.8 against a 1.16.1 pin
49(#522). The selftest therefore checks the spec is present on the pinned
50architecture and absent on the other, in both directions.
54 check_tool_versions.py # verify every pinned tool
55 check_tool_versions.py ruff shellcheck # verify only the named tools
56 check_tool_versions.py --all # verify every pinned tool (explicit)
57 check_tool_versions.py --selftest # prove the comparator both ways
59Exit 0 when every requested tool matches its pin, 1 when any tool is missing or
60the wrong version, 2 when the pin source itself cannot be read.
63from __future__
import annotations
74from collections.abc
import Callable
75from dataclasses
import dataclass
76from pathlib
import Path
77from unittest.mock
import patch
79REPO_ROOT = Path(__file__).resolve().parents[2]
80DOCKERFILE = REPO_ROOT /
".devcontainer" /
"Dockerfile"
81PYPROJECT = REPO_ROOT /
"pyproject.toml"
87DOXYGEN_PROVISIONER = REPO_ROOT /
"scripts" /
"builders" /
"provision_doxygen.sh"
93TOOL_TIMEOUT_SECONDS = 30
101_VERSION_RE = re.compile(
r"\d+(?:\.\d+)+")
102_ARG_RE = re.compile(
r"^\s*ARG\s+([A-Z0-9_]+)=(\S+)", re.MULTILINE)
115K_DOXYGEN_PINNED_MACHINE =
"x86_64"
116_DOXYGEN_ARCH_GUARD = f
'"$(uname -m)" = "{K_DOXYGEN_PINNED_MACHINE}"'
119@dataclass(frozen=True)
121 """One pinned tool: how to resolve it, run it, and judge its version.
124 binary: Executable resolved on PATH (e.g. "ruff", "clang-tidy-18").
125 expected: The pinned version, or the pinned major for major mode.
126 mode: Comparison mode (MODE_EXACT / MODE_MAJOR).
127 source: Human-readable origin of the pin, shown in failure messages.
128 version_args: Argument vector that makes the binary print its version.
135 version_args: tuple[str, ...] = (
"--version",)
138def _read_dockerfile() -> str:
139 """Return the devcontainer Dockerfile text, the pinned-version source.
142 The full Dockerfile contents.
145 FileNotFoundError: When the pinned-version source of truth is absent.
147 if not DOCKERFILE.is_file():
148 message = f
"pinned-version source of truth missing: {DOCKERFILE}"
149 raise FileNotFoundError(message)
150 return DOCKERFILE.read_text(encoding=
"utf-8")
153def _dockerfile_args(text: str) -> dict[str, str]:
154 """Parse every ``ARG NAME=value`` pin out of Dockerfile `text`.
157 text: The Dockerfile contents.
160 Mapping of ARG name to its pinned value.
162 return {match.group(1): match.group(2)
for match
in _ARG_RE.finditer(text)}
165def _arg(args: dict[str, str], key: str) -> str:
166 """Return the pinned value for `key`, failing loudly when it is gone.
169 args: Parsed Dockerfile ARG map.
170 key: The ARG name that must exist.
176 ValueError: When the pin is absent from the Dockerfile.
179 message = f
"Dockerfile no longer pins {key}; update {Path(__file__).name}"
180 raise ValueError(message)
184def _pkg_major(text: str, needle: str, label: str) -> str:
185 """Return the pinned major from a ``needle-NN`` package/binary token.
187 Used for the compiler families whose pin is carried in the package name
188 rather than an exact ARG: the clang-18 family and the gcc-14 arm (#356).
191 text: The Dockerfile contents.
192 needle: Package/binary stem preceding the major (e.g. "clang-format").
193 label: Human label used in the error message.
196 The major version, as text.
199 ValueError: When no ``needle-NN`` token is present.
201 match = re.search(rf
"{re.escape(needle)}-(\d+)", text)
203 message = f
"no pinned {label} major ({needle}-NN) in {DOCKERFILE}"
204 raise ValueError(message)
205 return match.group(1)
208def _upstream(value: str) -> str:
209 """Strip an apt/Debian revision suffix, keeping the upstream version.
212 value: An apt version such as "2.13.0-2ubuntu3" or "7.0-1".
215 The upstream portion before the final Debian-revision hyphen.
217 return value.rsplit(
"-", 1)[0]
if "-" in value
else value
221 args: dict[str, str],
225 transform: Callable[[str], str] |
None =
None,
227 """Build a ToolSpec whose pin comes from Dockerfile ARG `key`.
230 args: Parsed Dockerfile ARG map.
231 binary: Executable name to resolve on PATH.
232 key: The ARG whose value is the pin.
233 mode: Comparison mode (one of the MODE_* constants).
234 transform: Optional post-processor applied to the raw ARG value.
237 The assembled ToolSpec.
239 raw = _arg(args, key)
240 value = transform(raw)
if transform
is not None else raw
241 return ToolSpec(binary, value, mode, f
"ARG {key}")
244def _literal_shell_assignment(script: str, variable: str) -> str:
245 """Return one simple quoted shell assignment, rejecting drift-prone forms."""
246 pattern = re.compile(
247 rf
'^[ \t]*{re.escape(variable)}="(?P<value>[A-Za-z0-9._-]+)"[ \t]*$',
250 matches = list(pattern.finditer(script))
251 if len(matches) != 1:
252 message = f
"expected exactly one literal {variable} assignment, found {len(matches)}"
253 raise ValueError(message)
254 return matches[0].group(
"value")
257def _assert_doxygen_pin_stated_once(args: dict[str, str]) ->
None:
258 """Assert the Dockerfile and provision_doxygen.sh name the same release.
260 The doxygen pin is written down twice on purpose -- the Dockerfile bakes the
261 release into the image, and provision_doxygen.sh resolves it for the ``docs``
262 gate on hosts that have no such image -- and the Dockerfile's own comment
263 says to bump them together. Nothing enforced that, so "one release, cited
264 twice" was one release and a hope. A silent split would give the docs gate a
265 different doxygen from the one every other tool sees, which is the same
266 class of divergence this whole file exists to prevent.
269 args: Parsed Dockerfile ARG map.
272 ValueError: When either the version or the x86_64 sha256 disagrees, or
273 when the provisioner no longer states them in a readable form.
275 if not DOXYGEN_PROVISIONER.is_file():
276 message = f
"{DOXYGEN_PROVISIONER} is missing; the doxygen pin cannot be cross-checked"
277 raise ValueError(message)
278 script = DOXYGEN_PROVISIONER.read_text(encoding=
"utf-8")
280 (
"PINNED_VERSION",
"DOXYGEN_VERSION"),
281 (
"SHA256_LINUX_X64",
"DOXYGEN_SHA256_LINUX_X64"),
283 for var, arg
in pairs:
285 value = _literal_shell_assignment(script, var)
286 except ValueError
as exc:
287 message = f
"{DOXYGEN_PROVISIONER} no longer states {var}; update {Path(__file__).name}"
288 raise ValueError(message)
from exc
289 if value != _arg(args, arg):
291 f
"doxygen pin split: {DOCKERFILE.name} ARG {arg}={_arg(args, arg)} but "
292 f
"{DOXYGEN_PROVISIONER.name} {var}={value}. They are one release "
293 f
"cited twice and must be bumped together."
295 raise ValueError(message)
298def _python_pin(package: str, pyproject: Path = PYPROJECT) -> str:
299 """Read one and only one exact direct Python dependency declaration."""
300 document = tomllib.loads(pyproject.read_text(encoding=
"utf-8"))
301 groups = document.get(
"dependency-groups", {})
302 if not isinstance(groups, dict):
303 message = f
"{pyproject} has no dependency-groups table"
304 raise TypeError(message)
305 normalized = package.lower().replace(
"_",
"-")
306 matches: list[str] = []
307 for entries
in groups.values():
308 if not isinstance(entries, list):
310 for entry
in entries:
311 if not isinstance(entry, str):
313 parsed = re.fullmatch(
r"([A-Za-z0-9][A-Za-z0-9._-]*)(.*)", entry.strip())
316 name, declaration = parsed.groups()
317 if name.lower().replace(
"_",
"-") == normalized:
318 matches.append(declaration)
319 if len(matches) != 1:
320 message = f
"expected one direct {package} declaration in {pyproject}, found {matches}"
321 raise ValueError(message)
322 exact = re.fullmatch(
r"==([0-9][A-Za-z0-9.!+_-]*)", matches[0])
324 message = f
"{package} must have one bare exact == pin, found {matches[0]!r}"
325 raise ValueError(message)
326 return exact.group(1)
329def _python_spec(binary: str, package: str) -> ToolSpec:
330 """Build an exact tool spec from the locked Python project metadata.
333 binary: Executable resolved on PATH.
334 package: Distribution carrying the executable.
337 Exact ToolSpec sourced from pyproject.toml.
339 return ToolSpec(binary, _python_pin(package), MODE_EXACT, f
"pyproject.toml:{package}")
342def _doxygen_spec(text: str, args: dict[str, str]) -> ToolSpec |
None:
343 """Return the pinned-doxygen spec, or None where the Dockerfile pins none.
345 The ``docs`` gate itself was never exposed by this gap -- provision_doxygen.sh
346 resolves the pinned release into RA8_TOOLS_CACHE and prepends it to PATH, so
347 the gate gets the pin wherever it runs. The hole was in what
348 ``toolchain-parity`` asserted about the ENVIRONMENT: the deployed runner
349 image sat on apt's doxygen 1.9.8 against a 1.16.1 pin for as long as it did
350 because the one gate whose job is "pinned host tools match the Dockerfile"
351 was not looking at that tool (#522).
354 text: The Dockerfile contents.
355 args: Parsed Dockerfile ARG map.
358 The doxygen ToolSpec on an architecture the Dockerfile pins it for,
362 ValueError: When the Dockerfile no longer guards the install on the
363 architecture this function knows about, or no longer pins the
369 spec = _spec(args,
"doxygen",
"DOXYGEN_VERSION", MODE_EXACT)
370 _assert_doxygen_pin_stated_once(args)
371 if _DOXYGEN_ARCH_GUARD
not in text:
373 f
"{DOCKERFILE} no longer installs the pinned doxygen under "
374 f
"[ {_DOXYGEN_ARCH_GUARD} ]; update {Path(__file__).name} to match "
375 f
"whichever architectures it now pins"
377 raise ValueError(message)
378 if platform.machine() != K_DOXYGEN_PINNED_MACHINE:
383def build_specs() -> list[ToolSpec]:
384 """Assemble the pinned-tool registry from the Dockerfile source of truth.
387 Every pinned tool the CI gates resolve, each with its comparison rule.
390 FileNotFoundError: When the Dockerfile is missing.
391 ValueError: When a pin the registry needs is absent.
393 text = _read_dockerfile()
394 args = _dockerfile_args(text)
395 cf = _pkg_major(text,
"clang-format",
"clang-format")
396 ct = _pkg_major(text,
"clang-tools",
"clang-tidy")
397 gc = _pkg_major(text,
"gcc",
"gcc")
398 doxygen = _doxygen_spec(text, args)
400 _spec(args,
"just",
"JUST_VERSION", MODE_EXACT),
401 _python_spec(
"ruff",
"ruff"),
402 _spec(args,
"shellcheck",
"SHELLCHECK_VERSION", MODE_EXACT),
403 _spec(args,
"shfmt",
"SHFMT_VERSION", MODE_EXACT),
404 _spec(args,
"cppcheck",
"CPPCHECK_VERSION", MODE_EXACT, _upstream),
405 _python_spec(
"cmake-format",
"cmakelang"),
406 _python_spec(
"cmake-lint",
"cmakelang"),
407 _python_spec(
"yamllint",
"yamllint"),
408 _spec(args,
"actionlint",
"ACTIONLINT_VERSION", MODE_EXACT),
409 _spec(args,
"hadolint",
"HADOLINT_VERSION", MODE_EXACT),
411 ToolSpec(
"go", _arg(args,
"GO_VERSION"), MODE_EXACT,
"ARG GO_VERSION", (
"version",)),
412 ToolSpec(f
"clang-format-{cf}", cf, MODE_MAJOR, f
"clang-format-{cf}"),
413 ToolSpec(f
"clang-tidy-{ct}", ct, MODE_MAJOR, f
"clang-tools-{ct}"),
418 ToolSpec(f
"gcc-{gc}", gc, MODE_MAJOR, f
"gcc-{gc}"),
423 ToolSpec(f
"g++-{gc}", gc, MODE_MAJOR, f
"g++-{gc}"),
424 _python_spec(
"gcovr",
"gcovr"),
426 *([doxygen]
if doxygen
is not None else []),
430def _extract_version(text: str) -> str |
None:
431 """Return the first dotted version token in `text`, or None.
434 text: Combined stdout/stderr from a tool's version command.
437 The first ``N.N[.N...]`` token, or None when none is present.
439 match = _VERSION_RE.search(text)
440 return match.group(0)
if match
else None
443def _major(version: str) -> int:
444 """Return the integer major component of a dotted `version`.
447 version: A dotted version string such as "18.1.8".
450 The leading integer component.
452 return int(version.split(
".", 1)[0])
455def _matches(got: str, spec: ToolSpec) -> bool:
456 """Return whether resolved version `got` satisfies `spec`.
459 got: The version parsed from the tool.
460 spec: The pinned expectation and comparison mode.
463 True when `got` meets the pin under `spec.mode`.
466 ValueError: When `spec.mode` is not a known comparison mode.
468 if spec.mode == MODE_EXACT:
469 return got == spec.expected
470 if spec.mode == MODE_MAJOR:
471 return _major(got) == int(spec.expected)
472 message = f
"unknown comparison mode {spec.mode!r}"
473 raise ValueError(message)
476def _run_version(path: str, spec: ToolSpec) -> str:
477 """Run the tool's version command and return its combined output.
480 path: Absolute path to the resolved binary.
481 spec: The tool spec (supplies the version arguments).
484 Concatenated stdout and stderr from the version command.
486 proc = subprocess.run(
487 [path, *spec.version_args],
491 timeout=TOOL_TIMEOUT_SECONDS,
493 return proc.stdout + proc.stderr
496def verify(spec: ToolSpec) -> tuple[bool, str]:
497 """Resolve one pinned tool and judge its version against the pin.
500 spec: The pinned tool to check.
503 A ``(passed, message)`` pair; `passed` is False for a missing tool, an
504 unreadable version, or a version that does not meet the pin.
506 path = shutil.which(spec.binary)
508 missing = f
"{spec.binary}: NOT FOUND on PATH (want {spec.expected}, pin {spec.source})"
509 return (
False, missing)
511 output = _run_version(path, spec)
512 except (OSError, subprocess.SubprocessError)
as exc:
513 return (
False, f
"{spec.binary}: version command failed at {path} ({exc})")
514 got = _extract_version(output)
516 return (
False, f
"{spec.binary}: could not parse a version at {path}")
518 if _matches(got, spec):
519 return (
True, f
"{spec.binary} {got} [{rule} {spec.expected}] {path}")
520 return (
False, f
"{spec.binary} {got} != [{rule} {spec.expected}] pin {spec.source} at {path}")
523def _run_checks(specs: list[ToolSpec]) -> int:
524 """Verify each spec, print one line per tool, and return the aggregate code.
527 specs: The tool specs to verify.
530 EXIT_OK when all pass; EXIT_FAIL when any tool is missing or mismatched.
534 ok, message = verify(spec)
536 sys.stdout.write(f
"PASS {message}\n")
538 sys.stderr.write(f
"FAIL {message}\n")
541 sys.stderr.write(f
"check_tool_versions.py: {failed} tool(s) failed the version pin.\n")
543 print(f
"check_tool_versions.py: {len(specs)} pinned tool(s) match their pin.")
547def _select_specs(names: list[str], specs: list[ToolSpec]) -> list[ToolSpec]:
548 """Return the specs whose binary is in `names`, failing on an unknown name.
551 names: Requested tool binary names.
552 specs: The full registry.
555 The subset of `specs` whose binary is named in `names`.
558 ValueError: When a requested name is not a pinned tool.
560 by_name = {spec.binary: spec
for spec
in specs}
561 chosen: list[ToolSpec] = []
563 if name
not in by_name:
564 known =
", ".join(sorted(by_name))
565 message = f
"unknown pinned tool {name!r}; known: {known}"
566 raise ValueError(message)
567 chosen.append(by_name[name])
571def _family_binary(family: str, specs: list[ToolSpec]) -> str:
572 """Return the one major-pinned binary owned by a tool family.
575 family: Binary family prefix, for example ``clang-tidy``.
576 specs: The full registry derived from the owning pin sources.
579 The exact versioned binary name, for example ``clang-tidy-18``.
582 ValueError: When the family is absent, ambiguous, not major-pinned, or
583 its binary name does not encode the registered major exactly.
585 prefix = f
"{family}-"
586 matches = [spec
for spec
in specs
if spec.binary.startswith(prefix)]
587 if len(matches) != 1:
588 message = f
"expected one {family!r} family pin, found {len(matches)}"
589 raise ValueError(message)
591 if spec.mode != MODE_MAJOR:
592 message = f
"{spec.binary} uses {spec.mode!r}, not the required major pin"
593 raise ValueError(message)
594 expected_binary = f
"{family}-{spec.expected}"
595 if spec.binary != expected_binary:
597 f
"{family!r} family binary {spec.binary!r} does not encode "
598 f
"registered major {spec.expected!r}"
600 raise ValueError(message)
609def _write_fake(dir_path: Path, name: str, version_line: str) ->
None:
610 """Create an executable fake tool that prints `version_line` for --version.
613 dir_path: Directory to create the fake in (the caller puts it on PATH).
614 name: Executable base name.
615 version_line: The single line the fake prints.
617 script = dir_path / name
618 script.write_text(f
'#!/bin/sh\necho "{version_line}"\n', encoding=
"utf-8")
619 script.chmod(FAKE_TOOL_MODE)
622def _selftest_cases() -> list[tuple[ToolSpec, bool]]:
623 """Return the crafted ``(spec, expected_pass)`` selftest cases.
626 A case per mode in each direction, the gcovr exact-pin regression in
627 both directions, plus a deliberately missing tool.
630 (ToolSpec(
"ra8_fake_exact",
"1.2.3", MODE_EXACT,
"selftest"),
True),
631 (ToolSpec(
"ra8_fake_exact",
"9.9.9", MODE_EXACT,
"selftest"),
False),
632 (ToolSpec(
"ra8_fake_major18",
"18", MODE_MAJOR,
"selftest"),
True),
633 (ToolSpec(
"ra8_fake_major19",
"18", MODE_MAJOR,
"selftest"),
False),
634 (ToolSpec(
"ra8_fake_gcovr70",
"7.0", MODE_EXACT,
"selftest"),
True),
635 (ToolSpec(
"ra8_fake_gcovr86",
"7.0", MODE_EXACT,
"selftest"),
False),
636 (ToolSpec(
"ra8_fake_absent",
"1.0.0", MODE_EXACT,
"selftest"),
False),
640def _run_selftest_cases() -> list[str]:
641 """Verify every crafted case against fake tools on a temporary PATH.
644 A list of failure descriptions; empty when the comparator is correct.
646 failures: list[str] = []
647 saved_path = os.environ.get(
"PATH",
"")
648 with tempfile.TemporaryDirectory()
as tmp:
650 _write_fake(tmp_dir,
"ra8_fake_exact",
"faketool 1.2.3")
651 _write_fake(tmp_dir,
"ra8_fake_major18",
"Ubuntu LLVM version 18.1.8")
652 _write_fake(tmp_dir,
"ra8_fake_major19",
"Ubuntu LLVM version 19.1.0")
653 _write_fake(tmp_dir,
"ra8_fake_gcovr70",
"gcovr 7.0")
654 _write_fake(tmp_dir,
"ra8_fake_gcovr86",
"gcovr 8.6")
655 os.environ[
"PATH"] = f
"{tmp_dir}{os.pathsep}{saved_path}"
657 for spec, want_pass
in _selftest_cases():
658 got_pass, message = verify(spec)
659 if got_pass != want_pass:
660 want =
"pass" if want_pass
else "fail"
661 detail = f
"{spec.binary} [{spec.mode} {spec.expected}] want {want}: {message}"
662 failures.append(f
" {detail}")
664 os.environ[
"PATH"] = saved_path
668def _gcovr_registry_failures() -> list[str]:
669 """Verify the live gcovr spec is the exact uv-project package pin.
672 A list of failure descriptions; empty when the registry enforces the
673 pyproject.toml direct version exactly.
675 raw_pin = _python_pin(
"gcovr")
676 specs = [spec
for spec
in build_specs()
if spec.binary ==
"gcovr"]
678 return [f
" expected one gcovr spec, found {len(specs)}"]
680 failures: list[str] = []
681 if spec.mode != MODE_EXACT:
682 failures.append(f
" gcovr uses {spec.mode!r}, not exact comparison")
683 if spec.expected != raw_pin:
684 failures.append(f
" gcovr expects {spec.expected!r}, not uv project pin {raw_pin!r}")
688def _python_pin_failures() -> list[str]:
689 """Prove exact direct-pin parsing rejects every ambiguous declaration."""
691 "valid": ([
"ruff==1.2.3"],
True),
692 "missing": ([
"other==1.2.3"],
False),
693 "duplicate-same": ([
"ruff==1.2.3",
"ruff==1.2.3"],
False),
694 "duplicate-different": ([
"ruff==1.2.3",
"ruff==9.9.9"],
False),
695 "loose-plus-exact": ([
"ruff>=1",
"ruff==1.2.3"],
False),
696 "loose": ([
"ruff>=1.2.3"],
False),
697 "url": ([
"ruff @ https://example.invalid/ruff.whl"],
False),
698 "malformed": ([
"ruff===1.2.3"],
False),
700 failures: list[str] = []
701 with tempfile.TemporaryDirectory()
as tmp:
702 fixture = Path(tmp) /
"pyproject.toml"
703 for label, (entries, should_pass)
in fixtures.items():
704 joined =
'", "'.join(entries)
705 fixture.write_text(f
'[dependency-groups]\ndev = ["{joined}"]\n', encoding=
"utf-8")
707 value = _python_pin(
"ruff", fixture)
708 except (TypeError, ValueError):
711 passed = value ==
"1.2.3"
712 if passed != should_pass:
713 failures.append(f
" Python pin fixture {label!r} judged {passed}")
717def _shell_assignment_failures() -> list[str]:
718 """Prove indented literals pass while dynamic, duplicate, and loose forms fire."""
719 cases: dict[str, tuple[str, str |
None]] = {
720 "indented literal": (
' PINNED_VERSION="1.2.3"\n',
"1.2.3"),
721 "column-zero literal": (
'PINNED_VERSION="1.2.3"\n',
"1.2.3"),
722 "dynamic": (
' PINNED_VERSION="${VERSION}"\n',
None),
724 'PINNED_VERSION="1.2.3"\n PINNED_VERSION="1.2.3"\n',
727 "trailing command": (
'PINNED_VERSION="1.2.3"; run_tool\n',
None),
729 failures: list[str] = []
730 for label, (fixture, expected)
in cases.items():
732 actual = _literal_shell_assignment(fixture,
"PINNED_VERSION")
735 if actual != expected:
736 failures.append(f
" shell assignment fixture {label!r} returned {actual!r}")
740def _arch_conditional_failures() -> list[str]:
741 """Verify the doxygen spec appears exactly where the Dockerfile pins it.
743 The registry is otherwise unconditional, so this one spec is the only place
744 a mistake could silently drop a pin from the gate -- which is the state that
745 let a 1.9.8-against-1.16.1 drift survive in the deployed image (#522). Assert
746 both directions rather than trusting the condition.
749 A list of failure descriptions; empty when the spec is conditional as
752 failures: list[str] = []
753 text = _read_dockerfile()
754 args = _dockerfile_args(text)
755 with patch.object(platform,
"machine", return_value=K_DOXYGEN_PINNED_MACHINE):
756 spec = _doxygen_spec(text, args)
757 if spec
is None or spec.binary !=
"doxygen":
759 f
" no doxygen spec on {K_DOXYGEN_PINNED_MACHINE}, "
760 f
"where the Dockerfile installs the pinned release"
762 with patch.object(platform,
"machine", return_value=
"aarch64"):
763 if _doxygen_spec(text, args)
is not None:
765 " a doxygen spec on aarch64, where the Dockerfile deliberately "
766 "leaves apt's unpinned doxygen in place (no official arm64 build)"
771def _family_binary_failures() -> list[str]:
772 """Prove family lookup accepts one exact major pin and rejects drift.
775 A list of failure descriptions; empty when the lookup is two-sided.
777 failures: list[str] = []
778 valid = [ToolSpec(
"clang-tidy-18",
"18", MODE_MAJOR,
"selftest")]
780 selected = _family_binary(
"clang-tidy", valid)
781 except ValueError
as exc:
782 failures.append(f
" valid family pin was rejected: {exc}")
784 if selected !=
"clang-tidy-18":
785 failures.append(f
" valid family pin resolved as {selected!r}")
791 ToolSpec(
"clang-tidy-19",
"19", MODE_MAJOR,
"selftest"),
793 "wrong-mode": [ToolSpec(
"clang-tidy-18",
"18", MODE_EXACT,
"selftest")],
794 "name-major-drift": [ToolSpec(
"clang-tidy-19",
"18", MODE_MAJOR,
"selftest")],
796 for label, specs
in invalid_cases.items():
798 _family_binary(
"clang-tidy", specs)
801 failures.append(f
" invalid family fixture {label!r} was accepted")
805def _active_lines(text: str) -> list[str]:
806 """Return stripped non-comment lines from a shell-like consumer file."""
807 return [line.strip()
for line
in text.splitlines()
if not line.lstrip().startswith(
"#")]
810def _tidy_consumer_findings(just_text: str, gate_text: str, direct_text: str) -> list[str]:
811 """Validate all three clang-tidy consumers use the registry query.
814 just_text: Contents of ``just/ci.just``.
815 gate_text: Contents of the CI analysis gate body.
816 direct_text: Contents of the direct clang-tidy driver.
819 Stable finding identifiers; empty only for the required consumer shape.
821 just_lines = _active_lines(just_text)
822 gate_lines = _active_lines(gate_text)
823 direct_active =
"\n".join(_active_lines(direct_text))
824 findings: list[str] = []
826 "export CLANG_TIDY := env('CLANG_TIDY', `python3 "
827 "scripts/checks/check_tool_versions.py --print-binary clang-tidy`)"
829 if just_lines.count(just_query) != 1:
830 findings.append(
"just-query")
832 'pinned_tidy="$(python3 scripts/checks/check_tool_versions.py --print-binary clang-tidy)"'
834 gate_require =
'require_tool_versions "$pinned_tidy"'
835 gate_selftest =
'CLANG_TIDY="$pinned_tidy" bash scripts/checks/clang_tidy.sh --selftest'
837 'CLANG_TIDY="$pinned_tidy" bash scripts/checks/clang_tidy.sh '
838 '--check --verbose >"$log" 2>&1 || rc=$?'
840 gate_required = (gate_query, gate_require, gate_selftest, gate_check)
841 if any(gate_lines.count(line) != 1
for line
in gate_required):
842 findings.append(
"gate-query-or-consumer")
843 direct_query = re.compile(
844 r'if ! RA8_PINNED_CLANG_TIDY="\$\(\n\s*python3 '
845 r'"\$SCRIPT_DIR/check_tool_versions\.py" --print-binary clang-tidy\n\s*\)"; then'
847 if len(direct_query.findall(direct_active)) != 1:
848 findings.append(
"direct-query")
849 for label, active
in ((
"just", just_lines), (
"gate", gate_lines), (
"direct", direct_active)):
850 joined =
"\n".join(active)
if isinstance(active, list)
else active
851 if re.search(
r"\bclang-tidy-[0-9]+\b", joined):
852 findings.append(f
"{label}-hardcoded-major")
856def _tidy_consumer_failures() -> list[str]:
857 """Prove live and fixture consumers bind to the version registry."""
859 "export CLANG_TIDY := env('CLANG_TIDY', `python3 "
860 "scripts/checks/check_tool_versions.py --print-binary clang-tidy`)\n"
863 'pinned_tidy="$(python3 scripts/checks/check_tool_versions.py --print-binary clang-tidy)"'
865 gate_require =
'require_tool_versions "$pinned_tidy"'
866 gate_selftest =
'CLANG_TIDY="$pinned_tidy" bash scripts/checks/clang_tidy.sh --selftest'
868 'CLANG_TIDY="$pinned_tidy" bash scripts/checks/clang_tidy.sh '
869 '--check --verbose >"$log" 2>&1 || rc=$?'
871 valid_gate = f
"{gate_query}\n{gate_require}\n{gate_selftest}\n{gate_check}"
873 'if ! RA8_PINNED_CLANG_TIDY="$(\n'
874 ' python3 "$SCRIPT_DIR/check_tool_versions.py" --print-binary clang-tidy\n'
877 failures: list[str] = []
878 if _tidy_consumer_findings(valid_just, valid_gate, valid_direct):
879 failures.append(
" valid clang-tidy consumer fixture was rejected")
880 query_command =
"python3 scripts/checks/check_tool_versions.py --print-binary clang-tidy"
883 valid_just.replace(query_command,
"echo clang-tidy-18"),
889 valid_gate.replace(f
"$({query_command})",
"clang-tidy-18"),
894 valid_gate.replace(gate_require,
"require_tool_versions clang-tidy-18"),
900 'RA8_PINNED_CLANG_TIDY="clang-tidy-18"\n',
903 for label, fixture
in mutations.items():
904 if not _tidy_consumer_findings(*fixture):
905 failures.append(f
" clang-tidy consumer mutation {label!r} was accepted")
907 (REPO_ROOT /
"just/ci.just").read_text(encoding=
"utf-8"),
908 (REPO_ROOT /
"scripts/ci/gates/analysis.sh").read_text(encoding=
"utf-8"),
909 (REPO_ROOT /
"scripts/checks/clang_tidy.sh").read_text(encoding=
"utf-8"),
912 f
" live clang-tidy consumer: {item}" for item
in _tidy_consumer_findings(*live)
917def selftest() -> int:
918 """Prove the version comparator fires in both directions for every mode.
921 EXIT_OK when every crafted case (match and mismatch in each mode, plus a
922 missing tool) yields the expected verdict, and the one
923 architecture-conditional spec is present exactly where it belongs;
927 _run_selftest_cases()
928 + _gcovr_registry_failures()
929 + _python_pin_failures()
930 + _shell_assignment_failures()
931 + _arch_conditional_failures()
932 + _family_binary_failures()
933 + _tidy_consumer_failures()
936 sys.stderr.write(
"check_tool_versions.py --selftest: FAILED\n")
937 sys.stderr.write(
"\n".join(failures) +
"\n")
938 sys.stderr.write(
"The comparator does not judge versions as claimed.\n")
941 "check_tool_versions.py --selftest: OK (all modes and the gcovr exact "
942 "pin both ways, plus missing-tool and the arch-conditional doxygen pin)."
947def main(argv: list[str]) -> int:
948 """Parse arguments and run the selftest or the requested version checks.
951 argv: Process argument vector (``sys.argv``).
954 The process exit code: EXIT_OK, EXIT_FAIL, or EXIT_CONFIG.
956 parser = argparse.ArgumentParser(
957 description=
"Assert pinned host tools resolve to their pinned versions."
959 parser.add_argument(
"--selftest", action=
"store_true", help=
"prove the comparator both ways")
960 parser.add_argument(
"--all", action=
"store_true", help=
"verify every pinned tool (default)")
964 help=
"print the exact major-pinned binary owned by FAMILY without executing it",
966 parser.add_argument(
"names", nargs=
"*", help=
"tool binary names to verify (default: all)")
967 args = parser.parse_args(argv[1:])
971 if args.print_binary
is not None and (args.all
or args.names):
973 "check_tool_versions.py: FATAL -- --print-binary cannot be combined "
974 "with --all or tool names\n"
979 specs = build_specs()
980 if args.print_binary
is not None:
981 print(_family_binary(args.print_binary, specs))
983 chosen = specs
if (args.all
or not args.names)
else _select_specs(args.names, specs)
984 except (FileNotFoundError, ValueError)
as exc:
985 sys.stderr.write(f
"check_tool_versions.py: FATAL -- {exc}\n")
987 return _run_checks(chosen)
990if __name__ ==
"__main__":
991 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.