3"""Both-direction regression tests for the auditor, for both enforcing modes.
5Run before the real check in the gate, and for the reason this repository keeps
6rediscovering: a parser-driven gate that stops recognising a construct reports
7nothing and looks exactly like a documented tree. Every fixture below asserts
8that a real gap FIRES and that a correctly documented form of the same
9construct stays SILENT, so the auditor cannot be "fixed" by blinding it.
12from __future__
import annotations
16from pathlib
import Path
18from doxy_functions
import audit_file
19from doxy_members
import audit_members_file
20from doxy_scope
import override_repo_root
21from doxy_style
import _floor_failure, audit_text
31_SELFTEST_SOURCES: dict[str, str] = {
33 "libs/mod_doc/src/bad.c":
"""
34static int bad_no_block(int a)
40 * @brief Adds two numbers.
41 * @details Returns the arithmetic sum of its two arguments.
43 * @retval 0 Both arguments were zero.
45static int bad_thin_block(int a, int b)
50 "libs/mod_doc/inc/mod_doc.h":
"""
53 * @brief Return one value unchanged.
54 * @details Exercises a public definition whose included header owns the contract.
55 * @param[in] value Value to return.
56 * @return The original value.
57 * @retval 0 The input was zero.
58 * @pre @p value is initialised.
59 * @pre The caller accepts the unchanged result.
60 * @post No global state is mutated.
61 * @post The result equals @p value.
62 * @note Pure; thread-safe.
65int hdr_documented(int value);
67int hdr_undocumented(int value);
70 "libs/mod_doc/src/good.c":
"""
74 * @brief Doubles a value.
75 * @details Multiplies the input by two and returns it, saturating nothing.
76 * @param[in] a Value to double.
77 * @return The doubled value.
78 * @retval 0 The input was zero.
79 * @pre @p a is initialised.
80 * @pre The caller is single-threaded.
81 * @post No global state is mutated.
82 * @post The result is exactly 2*a.
83 * @note Pure; thread-safe.
86static int good_full_block(int a)
91/* A static forward prototype: an ordering device, not a second contract.
92 Demanding a block here too would force the identical block to be written
93 twice, which check_doc_attachment.py rejects as a duplicate. */
94static int good_fwd_declared(int a);
97 * @brief Triples a value.
98 * @details Multiplies the input by three and returns it.
99 * @param[in] a Value to triple.
100 * @return The tripled value.
101 * @retval 0 The input was zero.
102 * @pre @p a is initialised.
103 * @pre The caller is single-threaded.
104 * @post No global state is mutated.
105 * @post The result is exactly 3*a.
106 * @note Pure; thread-safe.
109static int good_fwd_declared(int a)
114/* A public forward declaration is likewise only an ordering device. The
115 C23 attribute exercises the live Cortex-M handler definition spelling. */
116void good_attr_forward(void);
119 * @brief Handle a synthetic weak interrupt.
120 * @details Exercises a documented attributed definition after a bare prototype.
121 * @pre The synthetic interrupt is active.
122 * @pre The caller accepts the handler side effects.
123 * @post The synthetic interrupt has been handled.
124 * @post Control returns to the caller.
125 * @note Synthetic self-test fixture only.
128[[gnu::weak]] void good_attr_forward(void)
132/* A matching prototype must stay quiet even if the RA8_WEAK definition is
133 bare; the definition itself must still fire so the gate keeps its teeth. */
134void bad_weak_forward(void);
136RA8_WEAK void bad_weak_forward(void)
140/* A lookalike definition with a different parameter type must not silence the
141 declaration. This pair should fire at the unmatched prototype. */
142void bad_mismatched_forward(int value);
144RA8_WEAK void bad_mismatched_forward(unsigned value)
149/* Definition-site policy: a non-static definition in a .c carries no block --
150 the header owns the contract. */
151int hdr_documented(int a)
154 /* `else if (...)` is the shape NON_FUNC_NAMES exists for: the regex sees
155 `else` as a return-type token and `if` as the function name. Same for
156 `__asm__ volatile("...")`, which parses as a function named `volatile`.
157 A bare `if (...)` would NOT exercise this -- it has no type token in
158 front of it, so FUNC_RE never matches it in the first place. */
161 total = good_full_block(a);
167 while (total > 100) {
170 __asm__ volatile("nop");
171 return total + good_fwd_declared(a);
174/* Public definitions are waived at the definition site, but their bare
175 header declarations must still be reported by the ordinary header audit. */
176int hdr_undocumented(int value)
183 "libs/mod_doc/src/contract_internal.h":
"""
186 * @brief Accept a byte array.
187 * @details Exercises name-independent array-to-pointer signature matching.
188 * @param[in] arguments Bytes accepted by the function.
189 * @return Whether the first byte is nonzero.
190 * @retval true The first byte is nonzero.
191 * @retval false The first byte is zero.
192 * @pre @p arguments points to one readable byte.
193 * @pre The caller retains ownership of @p arguments.
194 * @post No memory is modified.
195 * @post The result depends only on the first byte.
196 * @note Pure; thread-safe.
199[[nodiscard]] static bool contract_documented(const unsigned char arguments[]);
202 * @brief Incomplete on purpose.
203 * @details Missing the required contract tail on purpose.
204 * @param[in] value Input value.
205 * @return The original value.
206 * @retval 0 The input was zero.
208static int contract_incomplete(int value);
211 * @brief Document a different function.
212 * @details A complete contract with the wrong name must not mask a definition.
213 * @param[in] value Input value.
214 * @return The original value.
215 * @retval 0 The input was zero.
216 * @pre @p value is initialised.
217 * @pre The caller accepts the result.
218 * @post No memory is modified.
219 * @post The result equals @p value.
220 * @note Pure; thread-safe.
223static int contract_other_name(int value);
226 * @brief Public-linkage lookalike.
227 * @details A non-static declaration must not own a static definition contract.
228 * @param[in] value Input value.
229 * @return The original value.
230 * @retval 0 The input was zero.
231 * @pre @p value is initialised.
232 * @pre The caller accepts the result.
233 * @post No memory is modified.
234 * @post The result equals @p value.
235 * @note Pure; thread-safe.
238int contract_static_mismatch(int value);
241 * @brief Wrong-signature lookalike.
242 * @details A declaration with another parameter type cannot own the contract.
243 * @param[in] text Input text.
244 * @return Whether text was supplied.
245 * @retval true Text was supplied.
246 * @retval false Text was null.
247 * @pre @p text may be null.
248 * @pre The caller retains ownership of @p text.
249 * @post No memory is modified.
250 * @post The result depends only on @p text.
251 * @note Pure; thread-safe.
254static bool contract_signature_mismatch(const char* text);
256#include "contract_transitive_internal.h"
258 "libs/mod_doc/src/contract_transitive_internal.h":
"""
261 * @brief Document a transitively visible lookalike.
262 * @details A nested include must not own a bare definition's contract.
263 * @param[in] value Input value.
264 * @return The original value.
265 * @retval 0 The input was zero.
266 * @pre @p value is initialised.
267 * @pre The caller accepts the result.
268 * @post No memory is modified.
269 * @post The result equals @p value.
270 * @note Pure; thread-safe.
273static int contract_transitive(int value);
275 "libs/mod_doc/src/contract_inactive_internal.h":
"""
278 * @brief Document an inactive lookalike.
279 * @details A disabled include must not own a bare definition's contract.
280 * @param[in] value Input value.
281 * @return The original value.
282 * @retval 0 The input was zero.
283 * @pre @p value is initialised.
284 * @pre The caller accepts the result.
285 * @post No memory is modified.
286 * @post The result equals @p value.
287 * @note Pure; thread-safe.
290static int contract_inactive(int value);
292 "libs/mod_doc/src/contracts.c":
"""
293#include "contract_internal.h"
295#include "contract_inactive_internal.h"
298static bool contract_documented(const unsigned char* arg0)
303static int contract_incomplete(int value)
308static int contract_wrong_name(int value)
313static int contract_cross_scope(int value)
318static int contract_static_mismatch(int value)
323static bool contract_signature_mismatch(int value)
328static int contract_transitive(int value)
333static int contract_inactive(int value)
340 "libs/other/src/cross_internal.h":
"""
343 * @brief Cross-module lookalike.
344 * @details Must remain irrelevant unless the source explicitly includes it.
345 * @param[in] value Input value.
346 * @return The original value.
347 * @retval 0 The input was zero.
348 * @pre @p value is initialised.
349 * @pre The caller accepts the result.
350 * @post No memory is modified.
351 * @post The result equals @p value.
352 * @note Pure; thread-safe.
355static int contract_cross_scope(int value);
358 "libs/mod_doc/inc/members.h":
"""
361/** @brief A documented macro. */
362#define MOD_DOC_GOOD_MACRO 1
364#define MOD_DOC_BAD_MACRO 2
367 * @enum mod_doc_state_t
369 * @details The states this fixture can be in.
371typedef enum : unsigned char {
372 k_mod_doc_good = 0, /**< A documented enum value. */
377 * @struct mod_doc_cfg_t
379 * @details The configuration this fixture accepts.
382 int good_member; /**< A documented struct member. */
389_SELFTEST_FUNC_EXPECTED = frozenset(
392 "bad_mismatched_forward",
395 "contract_incomplete",
401_SELFTEST_FUNC_CLEAN = frozenset(
407 "contract_documented",
412_SELFTEST_FORWARD_ROW_COUNT = 2
415_SELFTEST_CONTRACT_GAPS = frozenset(
417 "contract_incomplete",
418 "contract_wrong_name",
419 "contract_cross_scope",
420 "contract_static_mismatch",
421 "contract_signature_mismatch",
422 "contract_transitive",
428_SELFTEST_MEMBER_EXPECTED = frozenset(
429 {(
"macro",
"MOD_DOC_BAD_MACRO"), (
"enum",
"k_mod_doc_bad"), (
"struct",
"bad_member")}
433_SELFTEST_MEMBER_CLEAN = frozenset({
"MOD_DOC_GOOD_MACRO",
"k_mod_doc_good",
"good_member"})
436def _audit_synthetic(root: Path) -> tuple[list, list]:
437 """Write the fixture tree under ``root`` and audit it in both modes."""
438 for rel, body
in _SELFTEST_SOURCES.items():
440 path.parent.mkdir(parents=
True, exist_ok=
True)
441 path.write_text(body, encoding=
"ascii")
442 func_rows, member_rows = [], []
443 for rel
in _SELFTEST_SOURCES:
445 func_rows.extend(audit_file(path))
446 member_rows.extend(audit_members_file(path))
447 return func_rows, member_rows
450def _check_same_file_forward_mode(func_rows: list) -> list[str]:
451 """Verify exact same-file prototype/definition association in both directions."""
453 name: [row
for row
in func_rows
if row[2] == name]
454 for name
in (
"good_attr_forward",
"bad_weak_forward",
"bad_mismatched_forward")
457 f
"same-file forward selftest did not parse both rows for '{name}'"
458 for name, rows
in forward_rows.items()
459 if len(rows) != _SELFTEST_FORWARD_ROW_COUNT
461 good_forward_gaps = [row
for row
in forward_rows[
"good_attr_forward"]
if row[3]]
462 if good_forward_gaps:
464 "same-file forward false positive: documented attributed definition "
465 "or its bare prototype was reported"
467 bad_forward_gaps = [row
for row
in forward_rows[
"bad_weak_forward"]
if row[3]]
468 if len(bad_forward_gaps) != 1
or (
470 and bad_forward_gaps[0][1] != max(row[1]
for row
in forward_rows[
"bad_weak_forward"])
473 "same-file forward gate went toothless: a bare RA8_WEAK definition "
474 "must fire exactly once at the definition, never at its prototype"
476 mismatched_gaps = [row
for row
in forward_rows[
"bad_mismatched_forward"]
if row[3]]
477 if len(mismatched_gaps) != 1
or (
479 and mismatched_gaps[0][1] !=
min(row[1]
for row
in forward_rows[
"bad_mismatched_forward"])
482 "same-file forward signature match went toothless: a prototype with "
483 "no exact definition must fire exactly once at the prototype"
488def _check_header_contract_mode(gaps: set[tuple[str, str]]) -> list[str]:
489 """Verify private header contracts associate only with exact definitions."""
490 contract_source_gaps = {name
for path, name
in gaps
if path ==
"libs/mod_doc/src/contracts.c"}
492 f
"header-contract lookup went toothless: bare definition '{name}' was "
493 "masked by a missing, incomplete, wrong-name, wrong-signature, "
494 "wrong-linkage, inactive, transitive, or out-of-scope declaration"
495 for name
in sorted(_SELFTEST_CONTRACT_GAPS - contract_source_gaps)
497 contract_documented_row = (
"libs/mod_doc/src/contracts.c",
"contract_documented")
498 if contract_documented_row
in gaps:
500 "header-contract lookup false positive: a complete included static "
501 "declaration with a compatible array/pointer signature was not associated"
506def _check_function_mode(func_rows: list) -> list[str]:
507 """The function gate must report every gap and spare every legal form.
509 This includes header-owned definitions, same-file forward prototypes, and
510 control-flow keywords that must never be mistaken for declarations.
512 gaps = {(r[0], r[2])
for r
in func_rows
if r[3]}
513 gap_names = {name
for _f, name
in gaps}
515 f
"function gate went toothless: '{name}' is undocumented and was not reported"
516 for name
in sorted(_SELFTEST_FUNC_EXPECTED - gap_names)
519 f
"function gate false positive: '{name}' in {path} is a legal form "
520 f
"(fully documented, forward prototype, or a definition whose header "
521 f
"owns the contract) but was reported"
522 for path, name
in sorted(gaps)
523 if name
in _SELFTEST_FUNC_CLEAN
525 parsed = {r[2]
for r
in func_rows}
526 parsed_pairs = {(r[0], r[2])
for r
in func_rows}
528 f
"selftest fixture did not parse: '{name}' never reached the auditor, "
529 f
"so its shape was never exercised"
530 for name
in sorted(_SELFTEST_FUNC_EXPECTED | _SELFTEST_FUNC_CLEAN)
531 if name
not in parsed
533 required_source_rows = {
534 (
"libs/mod_doc/src/contracts.c",
"contract_documented"),
535 (
"libs/mod_doc/src/good.c",
"hdr_undocumented"),
538 f
"selftest fixture did not parse source row: '{path}:{name}'"
539 for path, name
in sorted(required_source_rows - parsed_pairs)
541 if (
"libs/mod_doc/src/good.c",
"hdr_undocumented")
in gaps:
543 "definition-site policy false positive: a public .c definition was "
544 "reported instead of its undocumented header declaration"
546 failures.extend(_check_same_file_forward_mode(func_rows))
552 f
"function gate false positive: keyword '{bogus}' was parsed as a "
553 f
"function declaration (NON_FUNC_NAMES no longer filters it)"
554 for bogus
in (
"if",
"volatile")
558 failures.extend(_check_header_contract_mode(gaps))
562def _check_member_mode(member_rows: list) -> list[str]:
563 """The member gate must report undocumented members and spare documented ones."""
564 member_hits = {(r[2], r[3])
for r
in member_rows}
565 member_names = {name
for _k, name
in member_hits}
567 f
"member gate went toothless: undocumented {kind} '{name}' was not reported"
568 for kind, name
in sorted(_SELFTEST_MEMBER_EXPECTED)
569 if name
not in member_names
572 f
"member gate false positive: '{name}' carries a doc comment but was reported"
573 for name
in sorted(_SELFTEST_MEMBER_CLEAN & member_names)
582_STYLE_FIXTURES: tuple[tuple[str, str, frozenset[str]], ...] = (
584 "libs/mod_sty/src/no_block.c",
585 "/* not a doxygen block */\nint f(void) { return 0; }\n",
586 frozenset({
"FILE_BLOCK_MISSING"}),
589 "libs/mod_sty/src/wrong_name.c",
590 "/**\n * @file some_other_file.c\n * @brief B.\n * @details D.\n */\n",
591 frozenset({
"FILE_TAG_MISMATCH"}),
594 "libs/mod_sty/src/no_brief.c",
595 "/**\n * @file no_brief.c\n * @details D.\n */\n",
596 frozenset({
"BRIEF_MISSING"}),
599 "libs/mod_sty/src/no_details.c",
600 "/**\n * @file no_details.c\n * @brief B.\n */\n",
601 frozenset({
"DETAILS_MISSING"}),
604 "libs/mod_sty/src/bare_param.c",
605 "/**\n * @file bare_param.c\n * @brief B.\n * @details D.\n */\n"
606 "/** @brief g. @param a The input. */\n",
607 frozenset({
"PARAM_NO_DIRECTION"}),
610 "libs/mod_sty/src/bad_dir.c",
611 "/**\n * @file bad_dir.c\n * @brief B.\n * @details D.\n */\n"
612 "/** @brief g. @param[inout] a The input. */\n",
613 frozenset({
"PARAM_BAD_DIRECTION"}),
617 "libs/mod_sty/src/good.c",
618 "/**\n * @file good.c\n * @brief B.\n * @details D.\n */\n"
619 "/** @brief g. @param[in] a In. @param[out] b Out. @param[in,out] c Both. */\n",
625 "libs/mod_sty/src/backslash.c",
626 "/**\n * \\file backslash.c\n * \\brief B.\n * \\details D.\n */\n"
627 "/** \\brief g. \\param[in] a In. */\n",
633 "libs/mod_sty/src/continued.c",
634 "/**\n * @file\n * libs/mod_sty/src/continued.c\n * @brief B.\n * @details D.\n */\n",
639 "libs/mod_sty/src/full_path.c",
640 "/**\n * @file libs/mod_sty/src/full_path.c\n * @brief B.\n * @details D.\n */\n",
645 "libs/mod_sty/src/plain_comment.c",
646 "/**\n * @file plain_comment.c\n * @brief B.\n * @details D.\n */\n"
647 "/* @param a is written like this in the style guide's own examples */\n",
653def _check_style_mode() -> list[str]:
654 """The style gate must fire on each defect and spare each legal spelling."""
656 for rel, source, expected
in _STYLE_FIXTURES:
657 rows, _seen = audit_text(rel, source)
658 codes = {row[2]
for row
in rows}
660 f
"style gate went toothless: {rel} should report {code} and did not"
661 for code
in sorted(expected - codes)
664 f
"style gate false positive: {rel} is a legal form but reported {code}"
665 for code
in sorted(codes - expected)
670def _check_style_strict() -> list[str]:
671 """The closed @details debt must stay strict with no baseline."""
673 (
"libs/frozen.c", 1,
"DETAILS_MISSING",
"no @details"),
674 (
"libs/fresh.c", 1,
"DETAILS_MISSING",
"no @details"),
675 (
"libs/fresh.c", 9,
"PARAM_NO_DIRECTION",
"plain @param"),
677 offenders = {(row[0], row[2])
for row
in rows}
679 if (
"libs/frozen.c",
"DETAILS_MISSING")
not in offenders:
680 failures.append(
"strict style gate hid a formerly baselined @details gap")
681 if (
"libs/fresh.c",
"DETAILS_MISSING")
not in offenders:
682 failures.append(
"strict style gate hid a fresh @details gap")
683 if (
"libs/fresh.c",
"PARAM_NO_DIRECTION")
not in offenders:
684 failures.append(
"strict style gate hid a directionless @param")
688def _check_style_floor() -> list[str]:
689 """The vacuity floors must fire on a collapsed scan and pass a real one."""
691 if _floor_failure([], 0)
is None:
692 failures.append(
"style gate: an EMPTY file list did not trip the vacuity floor")
693 if _floor_failure([
"x.c"] * 9999, 0)
is None:
694 failures.append(
"style gate: ZERO @param tags did not trip the vacuity floor")
695 if _floor_failure([
"x.c"] * 9999, 99999)
is not None:
696 failures.append(
"style gate: a plausible scan was rejected by the vacuity floor")
700def run_selftest() -> int:
701 """Regression-test the auditor itself. Returns a process exit code.
703 ``--check``, ``--members --check`` and ``--style`` are all enforcing gates,
704 and a parser-driven gate has two ways to fail silently. It can stop
705 recognising a construct, in which case the offenders inside it vanish and
706 the tree looks documented; or it can start matching things that are not
707 declarations at all, in which case it reports noise until somebody
708 switches it off. Both directions are asserted, for all three modes.
710 with tempfile.TemporaryDirectory()
as td:
711 root = Path(td).resolve()
712 with override_repo_root(root):
713 func_rows, member_rows = _audit_synthetic(root)
716 *_check_function_mode(func_rows),
717 *_check_member_mode(member_rows),
718 *_check_style_mode(),
719 *_check_style_strict(),
720 *_check_style_floor(),
724 sys.stderr.write(f
"[FAIL] doxy_audit selftest: {f}\n")
725 sys.stderr.write(f
"doxy_audit selftest: {len(failures)} failure(s)\n")
728 "doxy_audit selftest: OK (function gate reports bare and thin blocks and "
729 "spares definition-site, forward-prototype and control-flow forms; static "
730 "header contracts match only by direct unconditional include, name, linkage and compatible "
731 "signature while incomplete and lookalike declarations fail; member gate "
732 "reports undocumented macros, enum values and struct members and spares "
733 "documented ones; style gate reports a missing/stale file header, a missing "
734 "@brief/@details and a directionless @param, spares the backslash, "
735 "continued-name and full-path spellings, keeps @details strict without a baseline, "
736 "and refuses a vacuous scan)"
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.