4"""Structural and formatting checker for hand-written GNU assembler sources.
6WHY THIS EXISTS INSTEAD OF AN OFF-THE-SHELF LINTER
7==================================================
8There is no linter for GNU `as`. This was searched for rather than assumed, and
9the conclusion is worth writing down so the next person does not repeat it:
11 * No tool in the shape of cmake-lint / yamllint / shellcheck exists for
12 assembly. The language is whatever the target's `as` accepts, there is no
13 published style guide to encode, and the analysers that do read assembly
14 (objdump, radare2, Ghidra) consume ASSEMBLED objects, not source text.
15 * `as` / `gcc -c` does validate syntax -- but only for one target, with that
16 target's toolchain present and the right `-I` and `-D` set. The first-party
17 assembly in the tree is `port/threadx/.../*.S`, Armv8.1-M Thumb, and only
18 arm-none-eabi is provisioned. An assemble-based gate would therefore be
19 silent on any source written for a target whose toolchain is absent -- and
20 a gate that skips is the exact defect this suite exists to eliminate.
21 * Where assembling IS possible it is already happening: the ThreadX port file
22 is assembled by the `build-cross` gate as part of every ThreadX app. This
23 text-level checker is the toolchain-independent layer on top of that, and is
24 the ONLY coverage for any assembly a provisioned toolchain cannot build.
26So this enforces what is mechanically checkable about assembly source as text,
27in the same spirit as `check_linker_scripts.py` (which exists because no linter
28for GNU ld script exists either):
30 AS001 licence header -- SPDX-License-Identifier and a Copyright line.
31 check-copyright.py's EXTENSIONS set covers
32 .c/.h/.cpp/.hpp/.cmake/.sh/.py and has never
33 included .S, so assembly was exempt from the
34 header rule the whole tree otherwise follows.
35 AS002 explicit section -- a `.section` / `.text` / `.data` directive before
36 the first label or instruction. Falling into the
37 assembler's default section is how hand-written
38 startup code silently lands somewhere the linker
39 script does not place.
40 AS003 exported symbols -- every `.globl` / `.global` symbol must have a
41 `.type`, a `.size`, and an actual label. Each
42 half is load-bearing: without `.type ...,%function`
43 the ARM linker does not set the Thumb bit and
44 indirect calls to the symbol land one byte off in
45 Arm state; without `.size` the symbol has length
46 zero, so `--gc-sections` may discard it and
47 debuggers cannot attribute a backtrace to it; and
48 a `.globl` with no label exports nothing at all.
49 AS004 unified syntax -- ARM sources must declare `.syntax unified`. The
50 pre-UAL divided syntax is still the assembler
51 DEFAULT, and it silently changes how flag-setting
52 mnemonics are parsed. Applied only to files that
53 carry an ARM-specific directive, so the RISC-V
54 source is not held to an ARM rule.
55 AS005 formatting -- 7-bit ASCII, LF endings, a final newline, no tab
56 indentation, no trailing whitespace.
58WHAT IS DELIBERATELY NOT ENFORCED
59---------------------------------
60Instruction-level style (operand column, mnemonic case, register naming) is not
61checked. The two files follow different house styles inherited from their
62upstreams -- the ThreadX port keeps Express Logic's deep indentation so it can
63be diffed against the vendor original, which is a real maintenance property.
64Imposing one layout would mean rewriting a file whose value is that it diffs
65cleanly, so the rule would cost more than it protects. That is a decision, not
66an oversight; AS005 still holds the parts that cannot be justified either way.
68Run with --selftest to prove every rule fires on a deliberately malformed
69source and stays quiet on a legal one.
72from __future__
import annotations
78from collections.abc
import Callable
79from pathlib
import Path
81sys.path.insert(0, str(Path(__file__).resolve().parent))
83from selftest_assert
import expect, report
87 [
"git",
"rev-parse",
"--show-toplevel"],
94EXCLUDED_PREFIXES = (
"libs/third_party/",
"apps/shared_libs/third_party/",
"libs/ra8_fonts/")
101_ARM_MARKERS = (
".thumb_func",
".eabi_attribute",
".cpu",
".fpu",
".arm",
".thumb")
104_SECTION_DIRECTIVES = (
".section",
".text",
".data",
".bss",
".rodata")
106_EXPORT_RE = re.compile(
r"^\s*\.(?:globl|global)\s+([A-Za-z_.$][\w.$]*)")
107_TYPE_RE = re.compile(
r"^\s*\.type\s+([A-Za-z_.$][\w.$]*)")
108_SIZE_RE = re.compile(
r"^\s*\.size\s+([A-Za-z_.$][\w.$]*)")
109_LABEL_RE = re.compile(
r"^\s*([A-Za-z_.$][\w.$]*)\s*:")
110_DIRECTIVE_RE = re.compile(
r"^\s*\.")
117_CPP_RE = re.compile(
r"^\s*#")
120def strip_comments(text: str) -> str:
121 """Blank out /* */ and // comment bodies, preserving line structure.
123 `#` is NOT treated as a comment marker: it introduces an immediate operand
124 on ARM (`mov r0, #1`) and a preprocessor directive on both targets. Blanking
125 it would erase real code.
128 def blank(match: re.Match[str]) -> str:
129 """Replace a comment's characters with spaces, keeping its newlines.
131 Preserving length and line breaks is what lets every later rule report
132 accurate line and column numbers against the blanked view.
134 return re.sub(
r"[^\n]",
" ", match.group(0))
136 text = re.sub(
r"/\*.*?\*/", blank, text, flags=re.DOTALL)
137 return re.sub(
r"//[^\n]*", blank, text)
141 """One rule violation, reported as path:line: [CODE] message."""
143 def __init__(self, rel: str, line: int, code: str, msg: str) ->
None:
144 """Record one finding; all four fields are required and none is derived."""
145 self.rel, self.line, self.code, self.msg = rel, line, code, msg
147 def __str__(self) -> str:
148 """Render as ``rel:line: [CODE] message`` -- editor-jumpable."""
149 return f
"{self.rel}:{self.line}: [{self.code}] {self.msg}"
152def _check_format(raw: bytes, add: Callable[[int, str, str],
None]) -> str:
153 """AS005, operating on the raw bytes. Returns the decoded text."""
155 text = raw.decode(
"ascii")
156 except UnicodeDecodeError
as exc:
157 add(raw[: exc.start].count(b
"\n") + 1,
"AS005", f
"non-ASCII byte 0x{raw[exc.start]:02x}")
158 text = raw.decode(
"ascii", errors=
"replace")
160 add(raw.split(b
"\r\n")[0].count(b
"\n") + 1,
"AS005",
"CRLF line ending")
161 if raw
and not raw.endswith(b
"\n"):
162 add(text.count(
"\n") + 1,
"AS005",
"no final newline")
163 for num, line
in enumerate(text.splitlines(), start=1):
164 if line.startswith(
"\t")
or re.match(
r"^ *\t", line):
165 add(num,
"AS005",
"tab indentation (use spaces)")
166 if line != line.rstrip():
167 add(num,
"AS005",
"trailing whitespace")
171def _check_exports(code_lines: list[str], add: Callable[[int, str, str],
None]) ->
None:
172 """AS003: every exported symbol has a type, a size and a label."""
173 exports: dict[str, int] = {}
174 typed: set[str] = set()
175 sized: set[str] = set()
176 labels: set[str] = set()
177 for num, line
in enumerate(code_lines, start=1):
178 match = _EXPORT_RE.match(line)
180 exports.setdefault(match.group(1), num)
181 match = _TYPE_RE.match(line)
183 typed.add(match.group(1))
184 match = _SIZE_RE.match(line)
186 sized.add(match.group(1))
187 match = _LABEL_RE.match(line)
188 if match
and not _DIRECTIVE_RE.match(line):
189 labels.add(match.group(1))
190 for sym, num
in sorted(exports.items(), key=
lambda kv: kv[1]):
191 if sym
not in labels:
192 add(num,
"AS003", f
"'{sym}' is exported but no label defines it in this file")
194 add(num,
"AS003", f
"'{sym}' is exported without a `.type {sym}, %function` directive")
196 add(num,
"AS003", f
"'{sym}' is exported without a `.size {sym}, . - {sym}` directive")
199def check_file(rel: str, raw: bytes) -> list[Finding]:
200 """Every finding for one assembly source."""
201 findings: list[Finding] = []
203 def add(line: int, code: str, msg: str) ->
None:
204 """Append one finding, closing over this file's relative path."""
205 findings.append(Finding(rel, line, code, msg))
207 text = _check_format(raw, add)
210 if "SPDX-License-Identifier" not in text:
211 add(1,
"AS001",
"no SPDX-License-Identifier in the file header")
212 if "Copyright" not in text:
213 add(1,
"AS001",
"no Copyright line in the file header")
215 code = strip_comments(text)
216 code_lines = code.splitlines()
220 for num, line
in enumerate(code_lines, start=1):
221 stripped = line.strip()
222 if not stripped
or _CPP_RE.match(line):
224 if stripped.startswith(_SECTION_DIRECTIVES):
227 if _LABEL_RE.match(line)
and not _DIRECTIVE_RE.match(line):
228 add(num,
"AS002", f
"label '{stripped}' precedes any .section directive")
230 if not _DIRECTIVE_RE.match(line):
231 add(num,
"AS002",
"instruction precedes any .section directive")
233 if section_at
is None and not any(
234 ln.strip().startswith(_SECTION_DIRECTIVES)
for ln
in code_lines
236 add(1,
"AS002",
"no .section / .text / .data directive anywhere in the file")
239 if any(marker
in code
for marker
in _ARM_MARKERS)
and ".syntax unified" not in code:
240 add(1,
"AS004",
"ARM source does not declare `.syntax unified` (divided is the default)")
242 _check_exports(code_lines, add)
246def targets() -> list[str]:
247 """Every first-party assembly source, repo-relative and sorted."""
248 proc = subprocess.run(
249 [
"git",
"ls-files",
"-z",
"*.S",
"*.s"],
256 rel
for rel
in proc.stdout.split(
"\0")
if rel
and not rel.startswith(EXCLUDED_PREFIXES)
261 * SPDX-License-Identifier: MIT
262 * Copyright (c) 2026 Brighton Sikarskie
264 .section .text.boot, "ax", @progbits
268 .type _start, %function
271 .size _start, . - _start
274BAD = b
" .globl _start\n_start:\n\tb _start \n .thumb_func\n"
278HEADER = b
"/*\n * SPDX-License-Identifier: MIT\n * Copyright (c) 2026 x\n */\n"
279CPP_BEFORE_SECTION = HEADER + b
'#ifdef TX_INCLUDE_USER_DEFINE_FILE\n#include "tx_user.h"\n#endif\n'
282def _assert_rule_codes(failures: list[str]) ->
None:
283 """Assert a conforming source is silent and a malformed one fires every rule."""
284 quiet = check_file(
"good.S", GOOD)
285 expect(
not quiet, f
"a conforming source yields no findings (got {quiet})", failures)
287 loud = check_file(
"bad.S", BAD)
288 codes = {f.code
for f
in loud}
290 (
"AS001",
"missing licence header fires"),
291 (
"AS002",
"a label before any .section fires"),
292 (
"AS003",
"an export with no .type / .size fires"),
293 (
"AS004",
"an ARM source without .syntax unified fires"),
294 (
"AS005",
"tab indentation and trailing whitespace fire"),
296 expect(code
in codes, why, failures)
299def _assert_as003_halves(failures: list[str]) ->
None:
300 """Assert each half of AS003 fires on its own.
302 AS003 is a three-part rule (``.type``, ``.size``, a defining label), and a
303 conjunction that only ever fires when all three are missing would pass the
304 all-at-once case above while missing every realistic defect.
306 no_size = GOOD.replace(b
" .size _start, . - _start\n", b
"")
308 any(
"without a `.size" in f.msg
for f
in check_file(
"x.S", no_size)),
309 "dropping only .size fires AS003",
312 no_type = GOOD.replace(b
" .type _start, %function\n", b
"")
314 any(
"without a `.type" in f.msg
for f
in check_file(
"x.S", no_type)),
315 "dropping only .type fires AS003",
318 no_label = GOOD.replace(b
"_start:\n", b
"")
320 any(
"no label defines it" in f.msg
for f
in check_file(
"x.S", no_label)),
321 "dropping only the label fires AS003",
326def _assert_section_scan(failures: list[str]) ->
None:
327 """Assert the section/syntax scan is not fooled by non-instruction text.
329 These are the false-positive cases: a rule that fires on a comment or on a
330 preprocessor line would make the gate unusable on the ThreadX port, and a
331 rule that holds a RISC-V source to an ARM directive is simply wrong.
334 riscv = GOOD.replace(b
" .syntax unified\n", b
"").replace(b
" .thumb_func\n", b
"")
336 not any(f.code ==
"AS004" for f
in check_file(
"rv.S", riscv)),
337 "a non-ARM source is not held to the ARM .syntax rule",
343 cpp = CPP_BEFORE_SECTION + GOOD.split(b
"*/\n", 1)[1]
345 not any(f.code ==
"AS002" for f
in check_file(
"cpp.S", cpp)),
346 "a #ifdef / #include before .section is not an instruction (AS002 quiet)",
350 any(f.code ==
"AS002" for f
in check_file(
"i.S", HEADER + b
" nop\n")),
351 "a real instruction before .section still fires AS002",
356 commented = GOOD.replace(b
" .section .text.boot", b
" /* .section */\n .section .x")
358 not any(f.code ==
"AS002" for f
in check_file(
"c.S", commented)),
359 "a commented-out directive does not confuse the section scan",
364def _assert_byte_hygiene(failures: list[str]) ->
None:
365 """Assert the AS005 byte-level rules (final newline, ASCII-only) fire."""
367 any(
"no final newline" in f.msg
for f
in check_file(
"n.S", GOOD.rstrip(b
"\n"))),
368 "a missing final newline fires AS005",
372 any(
"non-ASCII" in f.msg
for f
in check_file(
"u.S", GOOD.replace(b
"MIT", b
"MIT\xc2\xa9"))),
373 "a non-ASCII byte fires AS005",
378def selftest() -> int:
379 """Assert every rule fires on a malformed source and stays quiet on a good one."""
380 print(
"check_asm.py --selftest")
381 failures: list[str] = []
382 _assert_rule_codes(failures)
383 _assert_as003_halves(failures)
384 _assert_section_scan(failures)
385 _assert_byte_hygiene(failures)
386 return report(failures)
389def main(argv: list[str]) -> int:
390 """Check every tracked assembly file, or run the selftest / scope listing.
392 Assembly is the one language in the tree with no formatter and no
393 compiler-side style enforcement, so these structural rules are the only
394 thing standing between a .S file and arbitrary layout.
396 Returns 0 when clean, 1 on any finding or a failing selftest.
398 ap = argparse.ArgumentParser(description=
"Structural checker for GNU assembler sources")
399 ap.add_argument(
"--selftest", action=
"store_true", help=
"assert every rule, both directions")
400 ap.add_argument(
"--list-files", action=
"store_true", help=
"print the scanned file list")
401 args = ap.parse_args(argv[1:])
408 print(
"\n".join(files))
411 if len(files) < FILE_FLOOR:
413 f
"check_asm.py: FATAL -- {len(files)} assembly source(s) found, floor is "
414 f
"{FILE_FLOOR}. An empty scan reports success because it saw nothing.",
419 findings: list[Finding] = []
421 findings.extend(check_file(rel, (REPO_ROOT / rel).read_bytes()))
424 f
"\n{len(findings)} finding(s) in {len(files)} assembly source(s):\n", file=sys.stderr
426 for finding
in findings:
427 print(f
" {finding}", file=sys.stderr)
429 print(f
"check_asm.py: {len(files)} assembly source(s), no findings.")
433if __name__ ==
"__main__":
434 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.