ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
annot_linkage.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""The linkage rule: every non-static function states why it has external linkage.
4
5Separate from the other rules because it is the only one defined over the
6*absence* of an annotation. Every rule in :mod:`annot_rules` starts from a tag
7someone wrote and checks a property of it; this one starts from the whole
8symbol table and asks which definitions nobody has classified at all. That
9makes it the rule most likely to look clean by simply not running -- so it also
10owns the largest share of the selftest.
11"""
12
13from __future__ import annotations
14
15import pathlib
16
17from annot_model import AnnotatedSymbol, DataSymbol, Violation
18from annot_rulekeys import LINKAGE_ANNOTATIONS, parse_annotation
19from annot_scope import SOURCE_SUFFIXES, is_first_party, relative
20
21#: Header suffix that marks a declaration as library-private rather than
22#: published API. See CLAUDE.md, "Which linkage annotation to use".
23INTERNAL_HEADER_SUFFIX = "_internal.h"
24
25#: The ISO C program entry point. The startup code reaches `main` by name
26#: through the C runtime contract, so it has external linkage by
27#: definition and no first-party header declares it. Exactly one name --
28#: this is a language rule, not a naming convention.
29C_ENTRY_POINT = "main"
30
31#: Prefixes are linkage vocabulary, not interchangeable Hungarian notation.
32STATIC_FUNCTION_PREFIX = "internal_"
33PRIVATE_FUNCTION_PREFIX = "priv_"
34STATIC_DATA_PREFIX = "s_"
35
36
37def _is_published_inline_definition(sym: AnnotatedSymbol) -> bool:
38 """True for a static inline API body intentionally defined in public ``inc``."""
39 path = pathlib.PurePath(relative(sym.file))
40 return (
41 path.suffix in {".h", ".hpp"}
42 and INTERNAL_HEADER_SUFFIX not in path.name
43 and "inc" in path.parts
44 and sym.has_inline
45 )
46
47
48def _linkage_keys(sym: AnnotatedSymbol) -> set[str]:
49 """Return the linkage annotations attached to ``sym``."""
50 return {
51 parse_annotation(annotation)[0]
52 for annotation in sym.annotations
53 if parse_annotation(annotation)[0] in LINKAGE_ANNOTATIONS
54 }
55
56
57def _naming_at(sym: AnnotatedSymbol, message: str) -> Violation:
58 """Build one function naming finding at its definition site."""
59 return Violation("ra8_naming", sym.file, sym.line, message)
60
61
62def _annotation_shape(sym: AnnotatedSymbol, linkage: set[str]) -> list[Violation]:
63 """Check that every linkage annotation agrees with storage and prefix."""
64 out: list[Violation] = []
65 if len(linkage) > 1:
66 out.append(
67 _naming_at(
68 sym,
69 f"function '{sym.name}' carries conflicting linkage annotations: "
70 f"{', '.join(sorted(linkage))}",
71 )
72 )
73 if "ra8_internal" in linkage and not sym.name.startswith(STATIC_FUNCTION_PREFIX):
74 out.append(
75 _naming_at(sym, f"RA8_INTERNAL function '{sym.name}' must use the internal_ prefix")
76 )
77 if "ra8_priv" in linkage and sym.is_static:
78 out.append(_naming_at(sym, f"RA8_PRIV function '{sym.name}' must not be static"))
79 if "ra8_priv" in linkage and not sym.name.startswith(PRIVATE_FUNCTION_PREFIX):
80 out.append(_naming_at(sym, f"RA8_PRIV function '{sym.name}' must use the priv_ prefix"))
81 if "ra8_test_helper" in linkage and sym.is_static:
82 out.append(_naming_at(sym, f"RA8_TEST_HELPER function '{sym.name}' must not be static"))
83 return out
84
85
86def _static_shape(sym: AnnotatedSymbol, linkage: set[str]) -> list[Violation]:
87 """Check a non-public file-local function's storage, annotation, and prefix."""
88 if not sym.has_internal_linkage or _is_published_inline_definition(sym):
89 return []
90 out: list[Violation] = []
91 if not sym.is_static:
92 out.append(_naming_at(sym, f"file-local function '{sym.name}' must be declared static"))
93 if "ra8_internal" not in linkage:
94 out.append(_naming_at(sym, f"file-local function '{sym.name}' must carry RA8_INTERNAL"))
95 if not sym.name.startswith(STATIC_FUNCTION_PREFIX):
96 out.append(
97 _naming_at(sym, f"file-local function '{sym.name}' must use the internal_ prefix")
98 )
99 return out
100
101
102def _reserved_prefix_shape(sym: AnnotatedSymbol, linkage: set[str]) -> list[Violation]:
103 """Reserve ``internal_`` and ``priv_`` for their exact linkage shapes."""
104 out: list[Violation] = []
105 if sym.name.startswith(STATIC_FUNCTION_PREFIX):
106 if not sym.is_static:
107 out.append(
108 _naming_at(sym, f"function '{sym.name}' uses internal_ but is not declared static")
109 )
110 if "ra8_internal" not in linkage:
111 out.append(
112 _naming_at(sym, f"function '{sym.name}' uses internal_ but lacks RA8_INTERNAL")
113 )
114 if sym.name.startswith(PRIVATE_FUNCTION_PREFIX) and "ra8_priv" not in linkage:
115 out.append(_naming_at(sym, f"function '{sym.name}' uses priv_ but lacks RA8_PRIV"))
116 return out
117
118
119def _private_declaration_shape(sym: AnnotatedSymbol, linkage: set[str]) -> list[Violation]:
120 """Require an RA8_PRIV contract to be private and published nowhere else."""
121 if "ra8_priv" not in linkage:
122 return []
123 out: list[Violation] = []
124 if internal_header(sym) is None:
125 out.append(
126 _naming_at(
127 sym,
128 f"RA8_PRIV function '{sym.name}' has no declaration in a module *_internal.h",
129 )
130 )
131 public_decl = published_header(sym)
132 if public_decl is not None:
133 out.append(
134 _naming_at(
135 sym,
136 f"RA8_PRIV function '{sym.name}' is also published by {relative(public_decl)}",
137 )
138 )
139 return out
140
141
142def _function_naming(sym: AnnotatedSymbol) -> list[Violation]:
143 """Return every prefix/storage finding for one function definition."""
144 linkage = _linkage_keys(sym)
145 out: list[Violation] = []
146 if sym.name.startswith(STATIC_DATA_PREFIX):
147 out.append(
148 _naming_at(
149 sym,
150 f"function '{sym.name}' uses the s_ prefix reserved for file-scope static data",
151 )
152 )
153 out.extend(_annotation_shape(sym, linkage))
154 out.extend(_static_shape(sym, linkage))
155 out.extend(_reserved_prefix_shape(sym, linkage))
156 out.extend(_private_declaration_shape(sym, linkage))
157 return out
158
159
160def _data_naming(data: DataSymbol) -> Violation | None:
161 """Return the prefix/scope finding for one data definition, if any."""
162 correctly_static = data.is_file_scope and data.has_internal_linkage
163 if correctly_static and not data.name.startswith(STATIC_DATA_PREFIX):
164 return Violation(
165 "ra8_naming",
166 data.file,
167 data.line,
168 f"file-scope static data '{data.name}' must use the s_ prefix",
169 )
170 if data.name.startswith(STATIC_DATA_PREFIX) and not correctly_static:
171 return Violation(
172 "ra8_naming",
173 data.file,
174 data.line,
175 f"data '{data.name}' uses s_ but is not file-scope with internal linkage",
176 )
177 return None
178
179
180def _naming_violations(
181 symbols: dict[str, AnnotatedSymbol], data_symbols: dict[str, DataSymbol]
182) -> list[Violation]:
183 """Enforce and source-site-deduplicate the style-guide naming contract."""
184 findings = [
185 finding
186 for sym in symbols.values()
187 if sym.is_defined and is_first_party(sym.file)
188 for finding in _function_naming(sym)
189 ]
190 findings.extend(
191 finding
192 for data in data_symbols.values()
193 if is_first_party(data.file)
194 if (finding := _data_naming(data)) is not None
195 )
196 unique: dict[tuple[str, int, str], Violation] = {}
197 for finding in findings:
198 unique.setdefault((finding.file, finding.line, finding.message), finding)
199 return list(unique.values())
200
201
202def published_header(sym: AnnotatedSymbol) -> str | None:
203 """Return the header that publishes ``sym``, or None when none does.
204
205 A prototype in a header is an exported contract: a library's public
206 ``inc/`` header, an application's local header, a mock's header, or a
207 vendored SOUP header whose interface the function implements. A
208 prototype in a ``.c`` is a forward declaration and publishes nothing,
209 and an ``*_internal.h`` declaration is explicitly library-private --
210 both leave the function needing a linkage annotation.
211
212 "Header" is anything that is not a translation unit, rather than a
213 list of header suffixes: the C++ standard library headers that declare
214 the replaceable ``operator new`` / ``operator delete`` are spelled
215 ``<new>``, with no suffix at all.
216 """
217 for path in sorted(sym.decl_files):
218 name = pathlib.PurePath(path).name
219 if pathlib.PurePath(name).suffix in SOURCE_SUFFIXES:
220 continue
221 if name.endswith(INTERNAL_HEADER_SUFFIX):
222 continue
223 return path
224 return None
225
226
227def internal_header(sym: AnnotatedSymbol) -> str | None:
228 """Return the ``*_internal.h`` declaring ``sym``, or None."""
229 for path in sorted(sym.decl_files):
230 if pathlib.PurePath(path).name.endswith(INTERNAL_HEADER_SUFFIX):
231 return path
232 return None
233
234
235def _linkage_verdict(sym: AnnotatedSymbol, vector_entries: set[str]) -> Violation | None:
236 """Return the linkage violation for ``sym``, or None when it passes."""
237 if any(parse_annotation(a)[0] in LINKAGE_ANNOTATIONS for a in sym.annotations):
238 return None
239 if published_header(sym) is not None:
240 return None
241 if sym.usr in vector_entries or sym.name == C_ENTRY_POINT:
242 return None
243 internal = internal_header(sym)
244 if internal is not None:
245 return Violation(
246 "ra8_linkage",
247 sym.file,
248 sym.line,
249 f"'{sym.name}' is declared in {relative(internal)} but carries no "
250 f"linkage annotation; a symbol that is non-static only so one "
251 f"library's other TUs can reach it is RA8_PRIV (or RA8_TEST_HELPER "
252 f"when only tests call it)",
253 )
254 return Violation(
255 "ra8_linkage",
256 sym.file,
257 sym.line,
258 f"'{sym.name}' has external linkage but nothing publishes it: no header "
259 f"declares it and no vector table names it. Make it static and tag it "
260 f"RA8_INTERNAL, or declare it -- in the library's inc/ header if it is "
261 f"API, in an *_internal.h with RA8_PRIV if it is shared inside the library",
262 )
263
264
265def enforce_linkage(
266 symbols: dict[str, AnnotatedSymbol],
267 vector_entries: set[str],
268 data_symbols: dict[str, DataSymbol] | None = None,
269 *,
270 naming_contract: bool = False,
271) -> list[Violation]:
272 """Every non-static function must state why it has external linkage.
273
274 CLAUDE.md, "Which linkage annotation to use", makes this a tree-wide
275 expectation rather than an opt-in: a function that is not `static`
276 either publishes a contract other code may call, or it is deliberately
277 non-`static` for one narrow reason that has to be written down.
278
279 A definition passes when any of the following holds.
280
281 * It carries `RA8_PRIV`, `RA8_INTERNAL` or `RA8_TEST_HELPER`.
282 * A header publishes it. A prototype in a `.h` is an exported
283 contract -- a library's public `inc/` header, an application's local
284 header, a mock's header, or the vendored SOUP header whose interface
285 the function implements (the NimBLE `ble_npl_*` porting layer, the
286 ThreadX `tx_application_define` hook, the USBX class entry points).
287 An `*_internal.h` prototype does not count: that header exists to
288 say "library-private", which is what `RA8_PRIV` marks.
289 * It is a hardware vector-table entry -- see `is_vector_table()`. The
290 CPU reaches these through VTOR with no C caller at all, so no
291 annotation describes them and no header can publish them: the only
292 thing that names an IRQ trampoline is the table slot itself. The
293 category is derived from the table's structure, so a handler that is
294 removed from the table stops being exempt the moment it is unwired.
295 * It is `main`, the ISO C entry point.
296
297 Anything else is a gap, and the two shapes need different fixes, so
298 they get different messages.
299
300 A verdict is reached per *definition site*, not per symbol name. The
301 coverage tests reach a module's `static` helpers by `#define`-ing a
302 function to a `_cov` spelling and then `#include`-ing the `.c`, so one
303 source construct shows up under two names with two USRs and the same
304 file and line. Judging the renamed copy on its own would report the
305 original function as an unpublished symbol in a file that never
306 declared it.
307 """
308 out = _naming_violations(symbols, data_symbols or {}) if naming_contract else []
309 verdicts: dict[tuple[str, int], list[Violation | None]] = {}
310 for sym in symbols.values():
311 if not sym.is_defined or sym.has_internal_linkage or not is_first_party(sym.file):
312 continue
313 verdicts.setdefault((sym.file, sym.line), []).append(_linkage_verdict(sym, vector_entries))
314 for site in sorted(verdicts):
315 found = [v for v in verdicts[site] if v is not None]
316 if len(found) == len(verdicts[site]) and found:
317 out.append(found[0])
318 return out
static ra8_err_t internal_header(const ra8_fmt_sink_t *report, const ra8_fmt_source_t *source, const book_chunked_t *reader)
Emit the established RBKC header block.