diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae56cb95..2a6f8f24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -777,8 +777,9 @@ jobs: # the docs enumerate exactly what the code declares — a platform's pages, # nav entry, description and matrix row; a role's link from the porting # guide, the roles index and the nav — and the reverse in both cases. It - # also holds the two boundaries hand review kept losing: no platform names - # another, and every class a platform ships is on its page. + # also holds the three boundaries hand review kept losing: no platform + # names another, every class a platform declares carries its registry + # token, and every class a platform ships is on its page. - name: Check the docs match what the code declares run: python3 scripts/check_platform_docs.py diff --git a/docs/NAMING.md b/docs/NAMING.md index 20a52484..5e0d8a6b 100644 --- a/docs/NAMING.md +++ b/docs/NAMING.md @@ -30,6 +30,11 @@ split between the two tools so they cannot disagree on the same name: - **cppcheck-misra** is the sole authority on naming *uniqueness*. The MISRA addon surfaces rules 5.1, 5.2, 5.4, 5.6, 5.7, 5.8 and 5.9 violations, which pattern matching alone cannot detect. +- **`scripts/check_platform_docs.py`** is the sole authority on the one + rule neither tool can see — that a platform class carries its pack's + registry token, described under *Platform classes carry their pack's + registry token* below. The token is declared in CMake rather than in + the code, so no check reading only the translation unit can know it. cppcheck's `naming` addon is deliberately not used: it does less than clang-tidy on every axis we care about, and a second tool checking the diff --git a/scripts/check_platform_docs.py b/scripts/check_platform_docs.py index e14e9dfb..89db9869 100644 --- a/scripts/check_platform_docs.py +++ b/scripts/check_platform_docs.py @@ -19,12 +19,16 @@ folder or a group with no row behind it. Registering a platform is then the one edit that cannot be forgotten, because forgetting anything else fails the build. -It also holds two boundaries that hand review kept losing: +It also holds three boundaries that hand review kept losing: * **No platform names another.** A platform describes itself completely; where a capability comes from is the capability matrix's job. Naming a sibling couples the two, so the eleventh platform means editing ten pages. Applies to the platform's whole tree and its docs folder alike. +* **Every class a platform declares carries its token.** The rule is stated in + docs/NAMING.md, "Platform classes carry their pack's registry token", and had + nothing asserting it — which is how the naming drifted far enough to need a + rename. * **Every header its platform ships is on its page.** The *What it ships* manifest is generated from the Interface directory, so this asserts the heading the generator writes into is still there. @@ -68,6 +72,16 @@ "Windows": ["Winsock", "Win32"], } +# A pack wrapping a genuinely distinct second upstream prefixes those classes +# with that upstream instead of its token — docs/NAMING.md, "Platform classes +# carry their pack's registry token". Kept apart from ALIASES above, which is +# prose vocabulary: "POSIX" is how the Posix platform is written in a sentence, +# and must not become a licence to declare SolidSyslogPOSIXFile. An entry is +# agreed when the second upstream is taken on, so widening this is deliberate. +CLASS_PREFIXES = { + "Windows": ["Winsock"], +} + # Deliberate exemptions, each with the reason it is not a boundary breach. # Printed on every run: an exemption nobody sees is an exemption nobody # revisits. @@ -115,6 +129,14 @@ def documented(): } +def interface_headers(directory): + """The header filenames a platform declares, sorted. Empty if it declares none.""" + interface = os.path.join(ROOT, directory, "Interface") + if not os.path.isdir(interface): + return [] + return sorted(name for name in os.listdir(interface) if name.endswith(".h")) + + def vocabulary(rows): """Every term that names a platform, mapped to the tokens that may use it. @@ -128,14 +150,12 @@ def vocabulary(rows): """ terms = {} for token, directory in rows: - for alias in [token] + ALIASES.get(token, []): + for alias in [token, *ALIASES.get(token, [])]: terms.setdefault(alias, set()).add(token) - interface = os.path.join(ROOT, directory, "Interface") - for header in sorted(os.listdir(interface)) if os.path.isdir(interface) else []: - if header.endswith(".h"): - stem = header[: -len(".h")] - terms.setdefault(stem, set()).add(token) - terms.setdefault(stem[len("SolidSyslog") :], set()).add(token) + for header in interface_headers(directory): + stem = header[: -len(".h")] + terms.setdefault(stem, set()).add(token) + terms.setdefault(stem[len("SolidSyslog") :], set()).add(token) return terms @@ -240,6 +260,27 @@ def role_faults(): return faults +def prefix_faults(rows): + """Every header a platform declares begins with SolidSyslog, or with + an agreed second-upstream prefix from CLASS_PREFIXES. + + docs/NAMING.md states this for public classes. An *Errors.h takes its name + from the class it belongs to, so it carries the token by construction and is + checked alongside the rest — one that does not is an orphan worth catching. + """ + faults = [] + for token, directory in rows: + accepted = [token, *CLASS_PREFIXES.get(token, [])] + for header in interface_headers(directory): + if not any(header.startswith(f"SolidSyslog{prefix}") for prefix in accepted): + wanted = " or ".join(f"SolidSyslog{prefix}" for prefix in accepted) + faults.append( + f"{token}: {directory}/Interface/{header} does not begin {wanted} — " + "a platform's classes carry its registry token" + ) + return faults + + def unlisted_headers(rows): """The *What it ships* manifest is generated by hooks/platform_backlinks.py from the platform's own Interface directory, so every header is listed by @@ -286,6 +327,7 @@ def check(): for slug in sorted(documented() - slugs): faults.append(f"docs/platforms/{slug}/ documents a platform that is not registered") + faults.extend(prefix_faults(rows)) faults.extend(unlisted_headers(rows)) faults.extend(naming_faults(rows, vocabulary(rows))) faults.extend(role_faults()) @@ -305,7 +347,11 @@ def check(): sys.exit(1) for path, term, reason in ALLOWED: print(f"allowed: {path} may say {term} — {reason}") + for token, prefixes in sorted(CLASS_PREFIXES.items()): + spellings = ", ".join(f"SolidSyslog{prefix}*" for prefix in prefixes) + print(f"allowed: {token} may also declare {spellings} — a second upstream, agreed when it was taken on") print( - f"docs match the code: {len(registered())} platforms, all documented and none naming " - f"another; {len(declared_roles())} roles, each listed everywhere roles are enumerated" + f"docs match the code: {len(registered())} platforms, all documented, none naming " + f"another and each declaring only classes that carry its token; " + f"{len(declared_roles())} roles, each listed everywhere roles are enumerated" )