diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 59ead67e..3ed16980 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -82,12 +82,17 @@ reviews: Only flag a NEW deviation not covered there. Naming (docs/NAMING.md tiers): - - Public functions `SolidSyslogClass_Function`; public types - `SolidSyslogClass`; public macros/enum constants - `SOLIDSYSLOG_SCREAMING_SNAKE`; file-scope statics `Class_Function` - / `CLASS_SCREAMING_SNAKE`; struct members `PascalCase` (data AND - vtable function-pointer members alike); locals/params - `lowerCamelCase`. + - Tier 1 is decided by linkage, not by whether an integrator is meant + to call the identifier: anything declared in an Interface/ header, + and anything with external linkage declared in a `.c`, takes the + prefix — including a library-internal class that spans two + translation units from a `Core/Source/` `*Private.h` + (SolidSyslogRecordStore, SolidSyslogBlockSequence). Functions + `SolidSyslogClass_Function`; types `SolidSyslogClass`; macros/enum + constants `SOLIDSYSLOG_SCREAMING_SNAKE`. + - File-scope statics `Class_Function` / `CLASS_SCREAMING_SNAKE`; + struct members `PascalCase` (data AND vtable function-pointer + members alike); locals/params `lowerCamelCase`. - The this-pointer is the FIRST parameter, named by its declared type: `self` when the type is the concrete class, `base` when it is the abstract base struct. Flag any function that puts state after other @@ -95,8 +100,8 @@ reviews: - No `typedef struct` for project structs — refer to structs by tag. Typedefs allowed only for enums and function-pointer types. - No Hungarian; no `p`/`P` prefix or `Ptr` suffix on pointers; no - single-letter identifiers; out-params `outX`; booleans `isX`/`hasX`/ - `canX`; no lazy abbreviations (`buffer` not `buf`) but domain terms + single-letter identifiers; booleans `isX`/`hasX`/`canX`; no lazy + abbreviations (`buffer` not `buf`) but domain terms (tls/tcp/udp/crc/mq/dns/RFC field names) are real names, keep them. Clean Code: @@ -177,6 +182,23 @@ reviews: paths passed to CreateProcess; `127.0.0.1` not `localhost`. Prefer test-side portability fixes over changing production semantics. + # --------------------------------------------------------------------- + # Build files — CMake, make and workflows. These matched no entry until + # now, so nothing was asking anything of them. + # --------------------------------------------------------------------- + - path: "**/{CMakeLists.txt,Makefile,*.mk,*.cmake,*.yml,*.yaml}" + instructions: | + Build, make and CI files. Two things to hold: + - Naming follows docs/NAMING.md: options, cache variables and tunables + are `SOLIDSYSLOG_SCREAMING_SNAKE`, and a platform registry token is + spelled whole, exactly as its option spells it (`SOLIDSYSLOG_OPENSSL`, + never `SOLIDSYSLOG_OPEN_SSL`). + - A repo-relative path quoted here must resolve. + `scripts/check_references.py` asserts this in the docs-build lane, so + flag the ones it cannot see: a path built up from a variable, and a + path inside a shell block, which it skips because the working + directory there is the job's rather than the repository's. + # --------------------------------------------------------------------- # Docs — prose. Keep the tier model and conventions self-consistent. # --------------------------------------------------------------------- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7b5ef6e..55fad9b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -801,6 +801,13 @@ jobs: - name: Check the docs match what the code declares run: python3 scripts/check_platform_docs.py + # A path written in code font in a sentence, or in a CMake list, a + # Compose mount or a path filter, is text nothing reads back out — the + # link check below sees Markdown links only. This asserts every such path + # still exists, so a rename cannot leave a green reference to nothing. + - name: Check the paths documents and build files name exist + run: python3 scripts/check_references.py + # Guard the build hooks against regression — the source-link rewrite's # Markdown parsing, and the meta-description map's nav validation. # Image digest below is mkdocs-mkdoxy sha-34173c0 (see docs/containers.md). diff --git a/Bdd/Targets/FreeRtos/README.md b/Bdd/Targets/FreeRtos/README.md index e31ecac6..cc65210b 100644 --- a/Bdd/Targets/FreeRtos/README.md +++ b/Bdd/Targets/FreeRtos/README.md @@ -17,7 +17,7 @@ UART command channel with `set NAME VALUE`, flip the active transport at runtime with `switch udp` / `switch tcp` / `switch tls` / `switch mtls` (routed through `BddTargetSwitchConfig`), emit N RFC 5424 messages with `send N`, exit cleanly with `quit`. See -[`Bdd/README.md`](../../Bdd/README.md) for how Behave pipes these +[`Bdd/README.md`](../../../Bdd/README.md) for how Behave pipes these commands through `qemu-system-arm`'s stdio UART. **Persistent store-and-forward** is wired via the ChaN FatFs adapter @@ -46,7 +46,7 @@ syscalls, mps2-an385 linker script, startup) and `cmake/` the the cross-target `../Common/` pack (`Bdd/Targets/Common/`) since S29.01, shared with the lwIP BDD target. The `freertos-cross` CMake preset and the `freertos-target` devcontainer service -([`docs/containers.md`](../../docs/containers.md)) carry everything +([`docs/containers.md`](../../../docs/containers.md)) carry everything needed to build and run. ## Tuning for your MCU @@ -71,7 +71,7 @@ all equivalent). The simplest path. Switch into the FreeRTOS target devcontainer and use the standard Ctrl+Shift+B / F5 keys: -1. In [.devcontainer/devcontainer.json](../../.devcontainer/devcontainer.json), +1. In [.devcontainer/devcontainer.json](../../../.devcontainer/devcontainer.json), change `"service": "gcc"` to `"service": "freertos-target"`. 2. `Ctrl+Shift+P` → "Dev Containers: Rebuild Container". 3. **Build** — `Ctrl+Shift+B` runs the `build and test` task, which under diff --git a/docs/misra-deviations.md b/docs/misra-deviations.md index e00b951d..52037de4 100644 --- a/docs/misra-deviations.md +++ b/docs/misra-deviations.md @@ -429,13 +429,13 @@ Raised 2026-05-14, approved 2026-05-15 by the project owner, David Cozens. Recor ## D.004 — Rule 18.4: pointer arithmetic on record buffers (retired) **Retired in S10.19.** This deviation authorised `uint8_t*` pointer arithmetic in -`Core/Source/RecordStore.c`, where four field-offset helpers walked the +`Core/Source/SolidSyslogRecordStore.c`, where four field-offset helpers walked the `[magic][length][message]` record layout by adding an offset to a base address. S10.19 rewrote them to take the address of an indexed element (`&base[OFFSET]`) instead. That is the same address by definition, but rule 18.4 fires on the `+`, `-`, `+=` and `-=` operators specifically rather than on subscripting, so the finding no longer arises and the deviation had nothing left to authorise. -cppcheck-misra reports no 18.4 finding in `RecordStore.c`; the suppression was +cppcheck-misra reports no 18.4 finding in `SolidSyslogRecordStore.c`; the suppression was removed from `misra_suppressions.txt` at the same time. The entry is kept, rather than the number reused, so the register has no gaps and diff --git a/scripts/check_references.py b/scripts/check_references.py new file mode 100644 index 00000000..ace1753f --- /dev/null +++ b/scripts/check_references.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +"""Assert that what a document or a build file names still exists. + +Prose and build files are full of references into the repository, and almost +none of them are checked. `mkdocs build --strict` resolves a Markdown *link* +between two pages, and the docs-links workflow resolves an external URL, but a +path written in code font in a sentence is just text, and a path in a CMake +list, a Compose mount or a workflow's path filter is text nothing reads back +out. A rename leaves the reference behind, green, and pointing nowhere. + +This is one pass over those files, pulling out every reference of a known kind +and asserting each resolves. Today there is one kind, the repo-relative path. +The second, deferred to #740, is the SolidSyslog symbol a page names: the same +files, the same extraction pass, and the same exception problem, which is why it +belongs here as another KINDS row rather than in a second script. + +**The extraction is deliberately narrow**, because a heuristic loose enough to +need a long exception list is one that will be switched off. Three rules do the +work: + +* A token is read as a path only when its first segment is something git tracks + at the top of the repository, or `.` / `..`. That excludes an include of a + third-party header, a URL, and every `and/or` in a sentence without naming any + of them. What it costs is a top-level directory renamed wholesale. +* Anything git ignores is a build artefact rather than a reference — it does not + exist in a fresh checkout and asserting it would be asserting the build ran. +* A YAML block scalar is a shell script, not repository text. Its paths are + relative to a working directory this cannot know, and most of them name files + a job creates. Nothing is lost by leaving them: a shell command naming a path + that does not exist fails the job it is in, which is exactly what a nav entry + or a path filter does not do. + +**The exception list is the part to watch.** Each entry says why the reference +is meant not to resolve. All of them so far are one thing: a document quoting a +path as some *other* file would write it, to state a rule about how paths are +written. Past a handful of entries the extraction is wrong and should be +tightened rather than the list grown. + +Run: python3 scripts/check_references.py +""" + +import os +import re +import subprocess +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Deliberate exceptions, each with the reason the reference is meant not to +# resolve. Matched on (file, token). Printed on every run: an exception nobody +# sees is an exception nobody revisits. +ALLOWED = [ + ( + "CLAUDE.md", + "../LICENSE.md", + "states the rule for how a page under docs/ links a root document, so the " + "path is counted from that page rather than from here", + ), + ( + "CLAUDE.md", + "../../LICENSE.md", + "the same rule, counted from a page one level deeper", + ), + ( + "mkdocs.yml", + "../SECURITY.md", + "names a link as a docs page writes it, to say what the source-link hook rewrites", + ), + ( + ".github/workflows/ci.yml", + "../SECURITY.md", + "the same link, in the comment on the step that would fail if the hook " + "stopped rewriting it", + ), + ( + "Tests/Lwip/CMakeLists.txt", + "Tests/X/", + "the shape the next platform's test directory would take, not one that exists", + ), +] + +# Where references are looked for. Build files are here because nothing else +# reads them back: a path in a CMake list or a path filter is checked by the job +# it belongs to failing, months later, on a branch that did not touch it. +SCANNED_NAMES = ("CMakeLists.txt",) +SCANNED_SUFFIXES = (".md", ".yaml", ".yml", ".cmake") + +# A fence opens and closes a block whose whole content is candidate text rather +# than prose. +FENCE = re.compile(r"^\s*(?:```|~~~)") + +# Markdown carries references in two shapes: a code span, and a link target. +CODE_SPAN = re.compile(r"`([^`\n]+)`") +LINK_TARGET = re.compile(r"\]\(([^)\s]+)\)") + +# A YAML key introducing a block scalar, and the indent its body sits under. +BLOCK_SCALAR = re.compile(r"^(\s*)(?:-\s+)?[^:#]+:\s*[|>][-+0-9]*\s*(?:#.*)?$") + +# A step's script written inline rather than as a block. Same reasoning: it is +# shell, so its paths are the job's, not the repository's. +SHELL = re.compile(r"^\s*(?:-\s+)?(?:run|command|entrypoint):\s*\S") + +# Punctuation a token collects from the sentence or the syntax around it. A +# trailing full stop goes; a trailing `.md` does not, since `d` is not stripped. +LEADING = "([{<\"'`,;:" +TRAILING = ".,;:!?)]}>\"'`" + +# What disqualifies a word before its shape is considered: a URL or an address, +# a glob or a placeholder, a shell or CMake variable, or a character no path in +# this repository uses. Each names something other than one file here. +NOT_A_PATH = re.compile(r"://|[^A-Za-z0-9._/+:-]") + + +def read(relative): + with open(os.path.join(ROOT, relative), encoding="utf-8") as handle: + return handle.read() + + +def git(*arguments, stdin=None): + """Git is the authority on what this repository holds and what it ignores; + a hand-kept copy of either would be one more thing to keep in step.""" + return subprocess.run( + ["git", "-C", ROOT, *arguments], + input=stdin, + capture_output=True, + text=True, + check=False, + ).stdout.splitlines() + + +def tracked_roots(): + """What git tracks at the top of the repository — the first segment a path + must have to be read as one. Untracked output (`build/`, `site/`) is not + here, so nothing under it is ever mined.""" + return {name.split("/")[0] for name in git("ls-files")} + + +def ignored(paths): + """The subset git ignores: build artefacts, which a fresh checkout lacks.""" + if not paths: + return set() + return set(git("check-ignore", "--stdin", stdin="\n".join(sorted(paths)) + "\n")) + + +def scanned(): + """Every file references are looked for in, repo-relative and sorted. Taken + from git rather than from a walk, so generated trees never appear.""" + return sorted( + name + for name in git("ls-files") + if os.path.basename(name) in SCANNED_NAMES or name.endswith(SCANNED_SUFFIXES) + ) + + +def candidates(relative, line, verbatim): + """The text on one line that may hold a reference. + + Prose is mined only inside a code span or a link target — a path in a + sentence is written in code font by convention, and one that is not is prose + about a path rather than a reference to it. Verbatim text, a fenced block or + a build file, is candidate text whole. + """ + if relative.endswith(".md") and not verbatim: + return [m.group(1) for m in CODE_SPAN.finditer(line)] + [ + m.group(1) for m in LINK_TARGET.finditer(line) + ] + return [line] + + +def words(text): + """The path-shaped words of some candidate text, unpunctuated. + + A word is split on `:` after it has been judged, so a Compose mount + (`../Bdd/output:/var/log`) yields both sides and a URL yields neither. + """ + for word in text.split(): + word = word.lstrip(LEADING).rstrip(TRAILING) + if word and not NOT_A_PATH.search(word): + for part in word.split(":"): + yield part + + +def names_a_file(token): + """A path is written as one: it names a file, or it carries the trailing + slash that says it is a directory. + + The repository is full of slash-joined names that are not paths — a branch + (`ci/pin-action-shas`), a component (`Bdd/Targets/Common/BddTargetInteractive`), + a test (`Tests/Lwip/SolidSyslogLwipRawDnsResolverTest`), a pair of + directories written as one (`Core/Platform`). Requiring the shape separates + them without naming any of them, at the cost of a directory referred to + without its slash. + """ + tail = token.rsplit("/", 1)[-1] + return token.endswith("/") or ("." in tail and tail not in (".", "..")) + + +def paths_in(relative, line, verbatim, roots): + """Every token on this line that is a reference to a path in this repository. + + An anchor or a query names a place within the target rather than a different + target, so both are cut before the path is resolved. + """ + for text in candidates(relative, line, verbatim): + for word in words(text): + token = word.split("#")[0].split("?")[0] + if "/" not in token or not names_a_file(token): + continue + if token.split("/")[0] in roots or token.startswith((".", "..")): + yield token + + +def path_targets(relative, token): + """What a path token could mean, as repo-relative paths. + + Both spellings are used and both are correct: from the repository root, and + from the directory of the file that names it. Anything that normalises to + outside the repository is not a reference into it and drops out here. + """ + directory = os.path.dirname(relative) + spellings = {os.path.normpath(token), os.path.normpath(os.path.join(directory, token))} + return {p for p in spellings if not p.startswith("..") and not os.path.isabs(p)} + + +def path_resolves(relative, token): + return any(os.path.exists(os.path.join(ROOT, p)) for p in path_targets(relative, token)) + + +def path_artefacts(found): + """The references naming something the build produces rather than something + the repository holds. Asked of git in one call, since a reference costs + nothing to extract and a process costs a great deal to start.""" + artefacts = ignored({target for reference in found for target in path_targets(*reference)}) + return { + reference + for reference in found + if any(target in artefacts for target in path_targets(*reference)) + } + + +class Kind: + """One class of reference: how to find it, how to resolve it, what to say. + + `unassertable` is given every reference of this kind at once and returns + those that cannot be asserted at all, as against those that fail — a batch + so that a check needing to ask git something asks it once. + """ + + def __init__(self, extract, resolves, complaint, unassertable): + self.extract = extract + self.resolves = resolves + self.complaint = complaint + self.unassertable = unassertable + + +# The kinds of reference asserted. #740 adds the SolidSyslog symbol a page names +# as a second row here, reusing the pass above and the exception list below. +KINDS = ( + Kind( + extract=paths_in, + resolves=path_resolves, + complaint="names a path that does not exist", + unassertable=path_artefacts, + ), +) + + +def references(kind): + """Every (file, token) of one kind, with the line each was found on.""" + roots = tracked_roots() + found = {} + for relative in scanned(): + verbatim = not relative.endswith(".md") + block = None + for number, line in enumerate(read(relative).splitlines(), 1): + if relative.endswith(".md"): + if FENCE.match(line): + verbatim = not verbatim + continue + else: + block = inside_block(line, block) + if block is not None or SHELL.match(line): + continue + for token in kind.extract(relative, line, verbatim, roots): + found.setdefault((relative, token), number) + return found + + +def inside_block(line, block): + """The indent of the block scalar this line sits in, or None. A block ends + at the first non-blank line indented no further than the key that opened + it.""" + if block is not None: + if not line.strip() or len(line) - len(line.lstrip()) > block: + return block + opened = BLOCK_SCALAR.match(line) + return len(opened.group(1)) if opened else None + + +def check(): + # Everything below is asked of git, so a run outside a working tree would + # find no files, assert nothing, and pass. Fail loudly instead. + if not scanned(): + sys.exit(f"no documents or build files found under {ROOT} — is this a git checkout?") + + exempt = {(path, token) for path, token, _ in ALLOWED} + faults = [] + for kind in KINDS: + found = {r: n for r, n in references(kind).items() if r not in exempt} + assertable = set(found) - kind.unassertable(set(found)) + faults += [ + f"{relative}:{found[(relative, token)]} {kind.complaint}: {token}" + for relative, token in sorted(assertable) + if not kind.resolves(relative, token) + ] + return sorted(faults) + + +if __name__ == "__main__": + problems = check() + for problem in problems: + print(f"error: {problem}", file=sys.stderr) + if problems: + print( + f"\n{len(problems)} problem(s). Fix the reference, or — if it is " + "deliberately unresolvable — add it to ALLOWED with the reason.", + file=sys.stderr, + ) + sys.exit(1) + for path, token, reason in ALLOWED: + print(f"allowed: {path} may name {token} — {reason}") + print(f"every path named by {len(scanned())} documents and build files exists")