ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check-since-version.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"""Enforce ``@since`` Doxygen tags and check their values against VERSION.
5
6Both halves matter: a missing tag is a documentation gap, and a tag naming a
7version the project never released is worse, because it looks authoritative.
8
9Two checks combined:
10
11 1. **Presence**: every public declaration in a `.h` under
12 ``libs/ra8_*/inc/`` (i.e. every ``ra8_*`` function or static
13 inline accessor) must be preceded within the previous 30
14 lines by a ``@since`` tag inside its Doxygen block.
15
16 2. **Value**: every ``@since`` tag in any source / header /
17 example / test file must use the exact version string in
18 the project's top-level ``VERSION`` file. The tolerated
19 variants are::
20
21 @since 0.1.0
22 @since Version 0.1.0 (legacy STAR-style; still accepted)
23
24 Any other value is flagged.
25
26Usage:
27
28 # explicit file list (used by pre-commit hook):
29 python3 scripts/checks/check-since-version.py path/to/file.h ...
30
31 # full repo sweep (CI):
32 python3 scripts/checks/check-since-version.py --all
33
34The script always reads ``VERSION`` from the repo root, so a
35single bump there propagates everywhere.
36
37Exit code:
38 0 no issues
39 1 presence or value mismatch found
40 2 CLI usage error
41"""
42
43from __future__ import annotations
44
45import pathlib
46import re
47import sys
48import tempfile
49
50sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
51
52from lint_targets import first_party_paths
53from selftest_assert import expect, report
54
55REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
56VERSION_FILE = REPO_ROOT / "VERSION"
57
58SOURCE_SUFFIXES = (".c", ".h", ".cpp", ".hpp")
59
60PUBLIC_DECL = re.compile(
61 r"""
62 ^(?:\‍[\‍[nodiscard\‍]\‍]\s+)?
63 (?:static\s+inline\s+)?
64 \s*ra8_\w+(?:\s*\*)?\s+
65 (ra8_\w+)\s*
66 \‍(
67""",
68 re.VERBOSE,
69)
70
71SINCE_TAG_PRESENT = re.compile(r"@since")
72# Match ``@since 1.2.3`` or ``@since Version 1.2.3``; capture the version.
73SINCE_VALUE = re.compile(r"@since\s+(?:Version\s+)?([0-9]+(?:\.[0-9]+){1,2}[a-z]?)")
74
75
76def read_project_version() -> str:
77 """Read the single version string from the VERSION file.
78
79 Raises rather than defaulting when the file is missing: every ``@since``
80 comparison is against this value, so a default would silently validate
81 every tag in the tree against a number nobody chose.
82 """
83 if not VERSION_FILE.is_file():
84 msg = f"error: {VERSION_FILE} missing -- create it with a single semver line"
85 raise SystemExit(msg)
86 text = VERSION_FILE.read_text(encoding="utf-8").strip()
87 if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", text):
88 msg = f"error: {VERSION_FILE} content '{text}' is not semver MAJOR.MINOR.PATCH"
89 raise SystemExit(msg)
90 return text
91
92
93def check_presence(path: pathlib.Path) -> list[str]:
94 """Header-only: every ra8_* declaration must have @since in lookback."""
95 problems: list[str] = []
96 try:
97 lines = path.read_text(encoding="utf-8").splitlines()
98 except (OSError, UnicodeDecodeError):
99 return problems
100
101 for i, line in enumerate(lines):
102 match = PUBLIC_DECL.match(line)
103 if not match:
104 continue
105 lookback = "\n".join(lines[max(0, i - 30) : i])
106 if not SINCE_TAG_PRESENT.search(lookback):
107 problems.append(f"{path}:{i + 1}: {match.group(1)} missing @since")
108 return problems
109
110
111def check_values(path: pathlib.Path, project_version: str) -> list[str]:
112 """All sources: every @since's value must match project_version."""
113 problems: list[str] = []
114 try:
115 text = path.read_text(encoding="utf-8")
116 except (OSError, UnicodeDecodeError):
117 return problems
118
119 for line_no, line in enumerate(text.splitlines(), start=1):
120 m = SINCE_VALUE.search(line)
121 if not m:
122 continue
123 if m.group(1) != project_version:
124 problems.append(f"{path}:{line_no}: @since {m.group(1)} != project {project_version}")
125 return problems
126
127
128def is_under_lib_inc(path: pathlib.Path) -> bool:
129 """Whether this is a public library header, where ``@since`` is mandatory.
130
131 The tag is required only on the public contract: an ``inc/`` header of an
132 ``ra8_*`` library. Implementation files carry no API promise, so demanding
133 a version tag on them would be noise.
134 """
135 return "libs/ra8_" in str(path) and path.suffix == ".h" and "/inc/" in str(path)
136
137
138def collect_repo_paths() -> list[pathlib.Path]:
139 """Every first-party source file, for the ``--all`` whole-tree sweep.
140
141 Derived from git ls-files via first_party_paths (#358): the value check --
142 every ``@since`` must equal the single ``VERSION`` string -- now reaches
143 tools/, port/usbx and every future top-level directory, which the old
144 hardcoded libs/src/tests + example-app list silently omitted. The presence
145 check still fires only on libs/ra8_*/inc/ headers via ``is_under_lib_inc``,
146 so nothing else is newly *required* to carry a tag -- only its value is
147 validated. Without the flag the gate reads only the paths it is handed,
148 which is how the pre-commit hook stays cheap.
149 """
150 return [REPO_ROOT / rel for rel in first_party_paths(SOURCE_SUFFIXES)]
151
152
153# ---------------------------------------------------------------------------
154# Selftest -- both directions, plus a scope assertion under tools/, silently
155# omitted by the old scan-dir list until #358.
156# ---------------------------------------------------------------------------
157def selftest() -> int:
158 """Prove a wrong @since fires, a right one is quiet, and the scope holds."""
159 print("check-since-version.py --selftest")
160 failures: list[str] = []
161 version = read_project_version()
162 with tempfile.TemporaryDirectory() as tmp:
163 bad = pathlib.Path(tmp) / "bad.c"
164 bad.write_text("/** @since 9.9.9 */\n", encoding="utf-8")
165 good = pathlib.Path(tmp) / "good.c"
166 good.write_text(f"/** @since {version} */\n", encoding="utf-8")
167 expect(bool(check_values(bad, version)), "a wrong @since value fires", failures)
168 expect(not check_values(good, version), "the correct @since value stays quiet", failures)
169 hdr = pathlib.Path(tmp) / "decl.h"
170 hdr.write_text("ra8_err_t ra8_foo(void);\n", encoding="utf-8")
171 expect(bool(check_presence(hdr)), "a public decl missing @since fires", failures)
172
173 scope = set(first_party_paths(SOURCE_SUFFIXES))
174 expect(
175 any(s.startswith("tools/") for s in scope),
176 "tools/ is in scope (the scan-dir list omitted it before #358)",
177 failures,
178 )
179 expect(
180 not any(
181 s.startswith(("libs/third_party/", "apps/shared_libs/third_party/")) for s in scope
182 ),
183 "vendored SOUP stays out of scope",
184 failures,
185 )
186 return report(failures)
187
188
189def main() -> int:
190 """Check ``@since`` tags on public headers, staged files or the whole tree.
191
192 Resolves the project version FIRST, before any scanning, so a malformed
193 VERSION file fails immediately rather than after a full sweep whose
194 verdict would have been meaningless anyway.
195
196 Returns 0 when every public declaration carries a correct tag, 1 otherwise.
197 """
198 if "--selftest" in sys.argv[1:]:
199 return selftest()
200
201 project_version = read_project_version()
202
203 arguments = sys.argv[1:]
204 if arguments and arguments[0] == "--all":
205 paths = collect_repo_paths()
206 elif arguments:
207 paths = [pathlib.Path(p).resolve() for p in arguments]
208 else:
209 print("usage: check-since-version.py FILE [FILE ...] | --all", file=sys.stderr)
210 return 2
211
212 failures: list[str] = []
213 for path in paths:
214 if not path.is_file():
215 continue
216 if is_under_lib_inc(path):
217 failures.extend(check_presence(path))
218 if path.suffix in SOURCE_SUFFIXES:
219 failures.extend(check_values(path, project_version))
220
221 if failures:
222 print(f"check-since-version.py: project version is {project_version}", file=sys.stderr)
223 for line in failures:
224 print(line, file=sys.stderr)
225 print(f"\n{len(failures)} issue(s) found.", file=sys.stderr)
226 return 1
227 return 0
228
229
230if __name__ == "__main__":
231 raise SystemExit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298