ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_tool_versions.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Gate: assert every pinned host tool resolves to its project-pinned version.
5
6Why this exists (#333)
7----------------------
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.
17
18Single source of truth
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.
24
25Comparison modes
26----------------
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
37 it in its name.
38Non-vacuity
39-----------
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.
44
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.
51
52Run::
53
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
58
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.
61"""
62
63from __future__ import annotations
64
65import argparse
66import os
67import platform
68import re
69import shutil
70import subprocess
71import sys
72import tempfile
73import tomllib
74from collections.abc import Callable
75from dataclasses import dataclass
76from pathlib import Path
77from unittest.mock import patch
78
79REPO_ROOT = Path(__file__).resolve().parents[2]
80DOCKERFILE = REPO_ROOT / ".devcontainer" / "Dockerfile"
81PYPROJECT = REPO_ROOT / "pyproject.toml"
82
83# The second place the doxygen release is written down: the provisioner that
84# resolves it for the `docs` gate on a host with no devcontainer image. The
85# Dockerfile's own comment says to bump the two together; _assert_doxygen_pin_
86# stated_once is what makes that true rather than hoped for.
87DOXYGEN_PROVISIONER = REPO_ROOT / "scripts" / "builders" / "provision_doxygen.sh"
88
89EXIT_OK = 0
90EXIT_FAIL = 1
91EXIT_CONFIG = 2
92
93TOOL_TIMEOUT_SECONDS = 30
94FAKE_TOOL_MODE = 0o755
95
96MODE_EXACT = "exact"
97MODE_MAJOR = "major"
98
99# First dotted-number token (requires at least one dot, so a "2013-2023"
100# copyright range in a --version banner is never mistaken for the version).
101_VERSION_RE = re.compile(r"\d+(?:\.\d+)+")
102_ARG_RE = re.compile(r"^\s*ARG\s+([A-Z0-9_]+)=(\S+)", re.MULTILINE)
103
104# The machine the Dockerfile installs the pinned doxygen release on, and the
105# shell test it uses to decide. doxygen publishes no official linux-arm64
106# binary, so the Dockerfile keeps apt's unpinned doxygen on every other
107# architecture -- including the arm64 container a `just ci` on Apple Silicon
108# builds from this same file. Asserting the pin unconditionally would therefore
109# turn the container path red on every Mac.
110#
111# The guard string is checked against the Dockerfile rather than assumed: if
112# the install stops being architecture-conditional, this spec must stop being
113# conditional too, and a silent disagreement between the two is the shape of
114# bug that left the doxygen pin unchecked in the first place (#522).
115K_DOXYGEN_PINNED_MACHINE = "x86_64"
116_DOXYGEN_ARCH_GUARD = f'"$(uname -m)" = "{K_DOXYGEN_PINNED_MACHINE}"'
117
118
119@dataclass(frozen=True)
120class ToolSpec:
121 """One pinned tool: how to resolve it, run it, and judge its version.
122
123 Attributes:
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.
129 """
130
131 binary: str
132 expected: str
133 mode: str
134 source: str
135 version_args: tuple[str, ...] = ("--version",)
136
137
138def _read_dockerfile() -> str:
139 """Return the devcontainer Dockerfile text, the pinned-version source.
140
141 Returns:
142 The full Dockerfile contents.
143
144 Raises:
145 FileNotFoundError: When the pinned-version source of truth is absent.
146 """
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")
151
152
153def _dockerfile_args(text: str) -> dict[str, str]:
154 """Parse every ``ARG NAME=value`` pin out of Dockerfile `text`.
155
156 Args:
157 text: The Dockerfile contents.
158
159 Returns:
160 Mapping of ARG name to its pinned value.
161 """
162 return {match.group(1): match.group(2) for match in _ARG_RE.finditer(text)}
163
164
165def _arg(args: dict[str, str], key: str) -> str:
166 """Return the pinned value for `key`, failing loudly when it is gone.
167
168 Args:
169 args: Parsed Dockerfile ARG map.
170 key: The ARG name that must exist.
171
172 Returns:
173 The pinned value.
174
175 Raises:
176 ValueError: When the pin is absent from the Dockerfile.
177 """
178 if key not in args:
179 message = f"Dockerfile no longer pins {key}; update {Path(__file__).name}"
180 raise ValueError(message)
181 return args[key]
182
183
184def _pkg_major(text: str, needle: str, label: str) -> str:
185 """Return the pinned major from a ``needle-NN`` package/binary token.
186
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).
189
190 Args:
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.
194
195 Returns:
196 The major version, as text.
197
198 Raises:
199 ValueError: When no ``needle-NN`` token is present.
200 """
201 match = re.search(rf"{re.escape(needle)}-(\d+)", text)
202 if match is None:
203 message = f"no pinned {label} major ({needle}-NN) in {DOCKERFILE}"
204 raise ValueError(message)
205 return match.group(1)
206
207
208def _upstream(value: str) -> str:
209 """Strip an apt/Debian revision suffix, keeping the upstream version.
210
211 Args:
212 value: An apt version such as "2.13.0-2ubuntu3" or "7.0-1".
213
214 Returns:
215 The upstream portion before the final Debian-revision hyphen.
216 """
217 return value.rsplit("-", 1)[0] if "-" in value else value
218
219
220def _spec(
221 args: dict[str, str],
222 binary: str,
223 key: str,
224 mode: str,
225 transform: Callable[[str], str] | None = None,
226) -> ToolSpec:
227 """Build a ToolSpec whose pin comes from Dockerfile ARG `key`.
228
229 Args:
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.
235
236 Returns:
237 The assembled ToolSpec.
238 """
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}")
242
243
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]*$',
248 re.MULTILINE,
249 )
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")
255
256
257def _assert_doxygen_pin_stated_once(args: dict[str, str]) -> None:
258 """Assert the Dockerfile and provision_doxygen.sh name the same release.
259
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.
267
268 Args:
269 args: Parsed Dockerfile ARG map.
270
271 Raises:
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.
274 """
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")
279 pairs = (
280 ("PINNED_VERSION", "DOXYGEN_VERSION"),
281 ("SHA256_LINUX_X64", "DOXYGEN_SHA256_LINUX_X64"),
282 )
283 for var, arg in pairs:
284 try:
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):
290 message = (
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."
294 )
295 raise ValueError(message)
296
297
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):
309 continue
310 for entry in entries:
311 if not isinstance(entry, str):
312 continue
313 parsed = re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9._-]*)(.*)", entry.strip())
314 if parsed is None:
315 continue
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])
323 if exact is None:
324 message = f"{package} must have one bare exact == pin, found {matches[0]!r}"
325 raise ValueError(message)
326 return exact.group(1)
327
328
329def _python_spec(binary: str, package: str) -> ToolSpec:
330 """Build an exact tool spec from the locked Python project metadata.
331
332 Args:
333 binary: Executable resolved on PATH.
334 package: Distribution carrying the executable.
335
336 Returns:
337 Exact ToolSpec sourced from pyproject.toml.
338 """
339 return ToolSpec(binary, _python_pin(package), MODE_EXACT, f"pyproject.toml:{package}")
340
341
342def _doxygen_spec(text: str, args: dict[str, str]) -> ToolSpec | None:
343 """Return the pinned-doxygen spec, or None where the Dockerfile pins none.
344
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).
352
353 Args:
354 text: The Dockerfile contents.
355 args: Parsed Dockerfile ARG map.
356
357 Returns:
358 The doxygen ToolSpec on an architecture the Dockerfile pins it for,
359 None otherwise.
360
361 Raises:
362 ValueError: When the Dockerfile no longer guards the install on the
363 architecture this function knows about, or no longer pins the
364 version at all.
365 """
366 # Read the pin first, so a renamed ARG fails here rather than being skipped
367 # on an unpinned architecture and never noticed. Same for the cross-check:
368 # a split pin is wrong on every architecture, not only the pinned one.
369 spec = _spec(args, "doxygen", "DOXYGEN_VERSION", MODE_EXACT)
370 _assert_doxygen_pin_stated_once(args)
371 if _DOXYGEN_ARCH_GUARD not in text:
372 message = (
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"
376 )
377 raise ValueError(message)
378 if platform.machine() != K_DOXYGEN_PINNED_MACHINE:
379 return None
380 return spec
381
382
383def build_specs() -> list[ToolSpec]:
384 """Assemble the pinned-tool registry from the Dockerfile source of truth.
385
386 Returns:
387 Every pinned tool the CI gates resolve, each with its comparison rule.
388
389 Raises:
390 FileNotFoundError: When the Dockerfile is missing.
391 ValueError: When a pin the registry needs is absent.
392 """
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)
399 return [
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),
410 # `go --version` is not a thing: the toolchain spells it `go version`.
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}"),
414 # gcc-14 is the second host-tool compiler arm (#356); the tools-build
415 # gate resolves it by exact binary name, so pin its major like clang's.
416 # `gcc-14 --version` prints a dotted "14.2.0"; `-dumpversion` prints a
417 # bare "14" the dotted-token parser would reject, so keep the default.
418 ToolSpec(f"gcc-{gc}", gc, MODE_MAJOR, f"gcc-{gc}"),
419 # g++-14 is gcc-14's C++ half. The host-test and coverage builds
420 # enable_language(CXX), and the gcc-first selector picks gcc-14; a
421 # gcc-14 without g++-14 sank the coverage gate for hours. Pin the pair
422 # so every environment (devcontainer, runner pod, bare-metal) has both.
423 ToolSpec(f"g++-{gc}", gc, MODE_MAJOR, f"g++-{gc}"),
424 _python_spec("gcovr", "gcovr"),
425 # Pinned only where the Dockerfile pins it; see _doxygen_spec.
426 *([doxygen] if doxygen is not None else []),
427 ]
428
429
430def _extract_version(text: str) -> str | None:
431 """Return the first dotted version token in `text`, or None.
432
433 Args:
434 text: Combined stdout/stderr from a tool's version command.
435
436 Returns:
437 The first ``N.N[.N...]`` token, or None when none is present.
438 """
439 match = _VERSION_RE.search(text)
440 return match.group(0) if match else None
441
442
443def _major(version: str) -> int:
444 """Return the integer major component of a dotted `version`.
445
446 Args:
447 version: A dotted version string such as "18.1.8".
448
449 Returns:
450 The leading integer component.
451 """
452 return int(version.split(".", 1)[0])
453
454
455def _matches(got: str, spec: ToolSpec) -> bool:
456 """Return whether resolved version `got` satisfies `spec`.
457
458 Args:
459 got: The version parsed from the tool.
460 spec: The pinned expectation and comparison mode.
461
462 Returns:
463 True when `got` meets the pin under `spec.mode`.
464
465 Raises:
466 ValueError: When `spec.mode` is not a known comparison mode.
467 """
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)
474
475
476def _run_version(path: str, spec: ToolSpec) -> str:
477 """Run the tool's version command and return its combined output.
478
479 Args:
480 path: Absolute path to the resolved binary.
481 spec: The tool spec (supplies the version arguments).
482
483 Returns:
484 Concatenated stdout and stderr from the version command.
485 """
486 proc = subprocess.run( # noqa: S603 -- resolved absolute path, fixed argv
487 [path, *spec.version_args],
488 capture_output=True,
489 text=True,
490 check=False,
491 timeout=TOOL_TIMEOUT_SECONDS,
492 )
493 return proc.stdout + proc.stderr
494
495
496def verify(spec: ToolSpec) -> tuple[bool, str]:
497 """Resolve one pinned tool and judge its version against the pin.
498
499 Args:
500 spec: The pinned tool to check.
501
502 Returns:
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.
505 """
506 path = shutil.which(spec.binary)
507 if path is None:
508 missing = f"{spec.binary}: NOT FOUND on PATH (want {spec.expected}, pin {spec.source})"
509 return (False, missing)
510 try:
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)
515 if got is None:
516 return (False, f"{spec.binary}: could not parse a version at {path}")
517 rule = spec.mode
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}")
521
522
523def _run_checks(specs: list[ToolSpec]) -> int:
524 """Verify each spec, print one line per tool, and return the aggregate code.
525
526 Args:
527 specs: The tool specs to verify.
528
529 Returns:
530 EXIT_OK when all pass; EXIT_FAIL when any tool is missing or mismatched.
531 """
532 failed = 0
533 for spec in specs:
534 ok, message = verify(spec)
535 if ok:
536 sys.stdout.write(f"PASS {message}\n")
537 else:
538 sys.stderr.write(f"FAIL {message}\n")
539 failed += 1
540 if failed:
541 sys.stderr.write(f"check_tool_versions.py: {failed} tool(s) failed the version pin.\n")
542 return EXIT_FAIL
543 print(f"check_tool_versions.py: {len(specs)} pinned tool(s) match their pin.")
544 return EXIT_OK
545
546
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.
549
550 Args:
551 names: Requested tool binary names.
552 specs: The full registry.
553
554 Returns:
555 The subset of `specs` whose binary is named in `names`.
556
557 Raises:
558 ValueError: When a requested name is not a pinned tool.
559 """
560 by_name = {spec.binary: spec for spec in specs}
561 chosen: list[ToolSpec] = []
562 for name in names:
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])
568 return chosen
569
570
571def _family_binary(family: str, specs: list[ToolSpec]) -> str:
572 """Return the one major-pinned binary owned by a tool family.
573
574 Args:
575 family: Binary family prefix, for example ``clang-tidy``.
576 specs: The full registry derived from the owning pin sources.
577
578 Returns:
579 The exact versioned binary name, for example ``clang-tidy-18``.
580
581 Raises:
582 ValueError: When the family is absent, ambiguous, not major-pinned, or
583 its binary name does not encode the registered major exactly.
584 """
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)
590 spec = matches[0]
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:
596 message = (
597 f"{family!r} family binary {spec.binary!r} does not encode "
598 f"registered major {spec.expected!r}"
599 )
600 raise ValueError(message)
601 return spec.binary
602
603
604# ---------------------------------------------------------------------------
605# Selftest -- prove the comparator is non-vacuous in every mode, both ways.
606# ---------------------------------------------------------------------------
607
608
609def _write_fake(dir_path: Path, name: str, version_line: str) -> None:
610 """Create an executable fake tool that prints `version_line` for --version.
611
612 Args:
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.
616 """
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)
620
621
622def _selftest_cases() -> list[tuple[ToolSpec, bool]]:
623 """Return the crafted ``(spec, expected_pass)`` selftest cases.
624
625 Returns:
626 A case per mode in each direction, the gcovr exact-pin regression in
627 both directions, plus a deliberately missing tool.
628 """
629 return [
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),
637 ]
638
639
640def _run_selftest_cases() -> list[str]:
641 """Verify every crafted case against fake tools on a temporary PATH.
642
643 Returns:
644 A list of failure descriptions; empty when the comparator is correct.
645 """
646 failures: list[str] = []
647 saved_path = os.environ.get("PATH", "")
648 with tempfile.TemporaryDirectory() as tmp:
649 tmp_dir = Path(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}"
656 try:
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}")
663 finally:
664 os.environ["PATH"] = saved_path
665 return failures
666
667
668def _gcovr_registry_failures() -> list[str]:
669 """Verify the live gcovr spec is the exact uv-project package pin.
670
671 Returns:
672 A list of failure descriptions; empty when the registry enforces the
673 pyproject.toml direct version exactly.
674 """
675 raw_pin = _python_pin("gcovr")
676 specs = [spec for spec in build_specs() if spec.binary == "gcovr"]
677 if len(specs) != 1:
678 return [f" expected one gcovr spec, found {len(specs)}"]
679 spec = specs[0]
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}")
685 return failures
686
687
688def _python_pin_failures() -> list[str]:
689 """Prove exact direct-pin parsing rejects every ambiguous declaration."""
690 fixtures = {
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),
699 }
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")
706 try:
707 value = _python_pin("ruff", fixture)
708 except (TypeError, ValueError):
709 passed = False
710 else:
711 passed = value == "1.2.3"
712 if passed != should_pass:
713 failures.append(f" Python pin fixture {label!r} judged {passed}")
714 return failures
715
716
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),
723 "duplicate": (
724 'PINNED_VERSION="1.2.3"\n PINNED_VERSION="1.2.3"\n',
725 None,
726 ),
727 "trailing command": ('PINNED_VERSION="1.2.3"; run_tool\n', None),
728 }
729 failures: list[str] = []
730 for label, (fixture, expected) in cases.items():
731 try:
732 actual = _literal_shell_assignment(fixture, "PINNED_VERSION")
733 except ValueError:
734 actual = None
735 if actual != expected:
736 failures.append(f" shell assignment fixture {label!r} returned {actual!r}")
737 return failures
738
739
740def _arch_conditional_failures() -> list[str]:
741 """Verify the doxygen spec appears exactly where the Dockerfile pins it.
742
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.
747
748 Returns:
749 A list of failure descriptions; empty when the spec is conditional as
750 documented.
751 """
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":
758 failures.append(
759 f" no doxygen spec on {K_DOXYGEN_PINNED_MACHINE}, "
760 f"where the Dockerfile installs the pinned release"
761 )
762 with patch.object(platform, "machine", return_value="aarch64"):
763 if _doxygen_spec(text, args) is not None:
764 failures.append(
765 " a doxygen spec on aarch64, where the Dockerfile deliberately "
766 "leaves apt's unpinned doxygen in place (no official arm64 build)"
767 )
768 return failures
769
770
771def _family_binary_failures() -> list[str]:
772 """Prove family lookup accepts one exact major pin and rejects drift.
773
774 Returns:
775 A list of failure descriptions; empty when the lookup is two-sided.
776 """
777 failures: list[str] = []
778 valid = [ToolSpec("clang-tidy-18", "18", MODE_MAJOR, "selftest")]
779 try:
780 selected = _family_binary("clang-tidy", valid)
781 except ValueError as exc:
782 failures.append(f" valid family pin was rejected: {exc}")
783 else:
784 if selected != "clang-tidy-18":
785 failures.append(f" valid family pin resolved as {selected!r}")
786
787 invalid_cases = {
788 "absent": [],
789 "ambiguous": [
790 *valid,
791 ToolSpec("clang-tidy-19", "19", MODE_MAJOR, "selftest"),
792 ],
793 "wrong-mode": [ToolSpec("clang-tidy-18", "18", MODE_EXACT, "selftest")],
794 "name-major-drift": [ToolSpec("clang-tidy-19", "18", MODE_MAJOR, "selftest")],
795 }
796 for label, specs in invalid_cases.items():
797 try:
798 _family_binary("clang-tidy", specs)
799 except ValueError:
800 continue
801 failures.append(f" invalid family fixture {label!r} was accepted")
802 return failures
803
804
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("#")]
808
809
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.
812
813 Args:
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.
817
818 Returns:
819 Stable finding identifiers; empty only for the required consumer shape.
820 """
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] = []
825 just_query = (
826 "export CLANG_TIDY := env('CLANG_TIDY', `python3 "
827 "scripts/checks/check_tool_versions.py --print-binary clang-tidy`)"
828 )
829 if just_lines.count(just_query) != 1:
830 findings.append("just-query")
831 gate_query = (
832 'pinned_tidy="$(python3 scripts/checks/check_tool_versions.py --print-binary clang-tidy)"'
833 )
834 gate_require = 'require_tool_versions "$pinned_tidy"'
835 gate_selftest = 'CLANG_TIDY="$pinned_tidy" bash scripts/checks/clang_tidy.sh --selftest'
836 gate_check = (
837 'CLANG_TIDY="$pinned_tidy" bash scripts/checks/clang_tidy.sh '
838 '--check --verbose >"$log" 2>&1 || rc=$?'
839 )
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'
846 )
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")
853 return findings
854
855
856def _tidy_consumer_failures() -> list[str]:
857 """Prove live and fixture consumers bind to the version registry."""
858 valid_just = (
859 "export CLANG_TIDY := env('CLANG_TIDY', `python3 "
860 "scripts/checks/check_tool_versions.py --print-binary clang-tidy`)\n"
861 )
862 gate_query = (
863 'pinned_tidy="$(python3 scripts/checks/check_tool_versions.py --print-binary clang-tidy)"'
864 )
865 gate_require = 'require_tool_versions "$pinned_tidy"'
866 gate_selftest = 'CLANG_TIDY="$pinned_tidy" bash scripts/checks/clang_tidy.sh --selftest'
867 gate_check = (
868 'CLANG_TIDY="$pinned_tidy" bash scripts/checks/clang_tidy.sh '
869 '--check --verbose >"$log" 2>&1 || rc=$?'
870 )
871 valid_gate = f"{gate_query}\n{gate_require}\n{gate_selftest}\n{gate_check}"
872 valid_direct = (
873 'if ! RA8_PINNED_CLANG_TIDY="$(\n'
874 ' python3 "$SCRIPT_DIR/check_tool_versions.py" --print-binary clang-tidy\n'
875 ')"; then\n'
876 )
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"
881 mutations = {
882 "just hardcode": (
883 valid_just.replace(query_command, "echo clang-tidy-18"),
884 valid_gate,
885 valid_direct,
886 ),
887 "gate hardcode": (
888 valid_just,
889 valid_gate.replace(f"$({query_command})", "clang-tidy-18"),
890 valid_direct,
891 ),
892 "gate bypass": (
893 valid_just,
894 valid_gate.replace(gate_require, "require_tool_versions clang-tidy-18"),
895 valid_direct,
896 ),
897 "direct hardcode": (
898 valid_just,
899 valid_gate,
900 'RA8_PINNED_CLANG_TIDY="clang-tidy-18"\n',
901 ),
902 }
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")
906 live = (
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"),
910 )
911 failures.extend(
912 f" live clang-tidy consumer: {item}" for item in _tidy_consumer_findings(*live)
913 )
914 return failures
915
916
917def selftest() -> int:
918 """Prove the version comparator fires in both directions for every mode.
919
920 Returns:
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;
924 EXIT_FAIL otherwise.
925 """
926 failures = (
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()
934 )
935 if 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")
939 return EXIT_FAIL
940 print(
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)."
943 )
944 return EXIT_OK
945
946
947def main(argv: list[str]) -> int:
948 """Parse arguments and run the selftest or the requested version checks.
949
950 Args:
951 argv: Process argument vector (``sys.argv``).
952
953 Returns:
954 The process exit code: EXIT_OK, EXIT_FAIL, or EXIT_CONFIG.
955 """
956 parser = argparse.ArgumentParser(
957 description="Assert pinned host tools resolve to their pinned versions."
958 )
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)")
961 parser.add_argument(
962 "--print-binary",
963 metavar="FAMILY",
964 help="print the exact major-pinned binary owned by FAMILY without executing it",
965 )
966 parser.add_argument("names", nargs="*", help="tool binary names to verify (default: all)")
967 args = parser.parse_args(argv[1:])
968
969 if args.selftest:
970 return selftest()
971 if args.print_binary is not None and (args.all or args.names):
972 sys.stderr.write(
973 "check_tool_versions.py: FATAL -- --print-binary cannot be combined "
974 "with --all or tool names\n"
975 )
976 return EXIT_CONFIG
977
978 try:
979 specs = build_specs()
980 if args.print_binary is not None:
981 print(_family_binary(args.print_binary, specs))
982 return EXIT_OK
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")
986 return EXIT_CONFIG
987 return _run_checks(chosen)
988
989
990if __name__ == "__main__":
991 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298