#!/usr/bin/env python3 """Project-specific lint rules for the Monkey C sources and the manifest. The Monkey C compiler (strict type checking, `make lint`) catches language errors. These rules catch project policy violations the compiler can't see: MC001 every .mc file starts with a comment header explaining the file MC002 System.println only in source/util/Diag.mc (no logging, so no secret can ever end up in a log) and in test code MC003 no TODO / FIXME / XXX markers in source/ (finished code only) MC004 no secret-looking string literals in source/ (long Base32 or hex runs); real secrets never belong in code, test vectors live in test/. A literal that is not a secret (for example the Base32 alphabet) can be allowed with the comment `// lint: allow-literal` on its line. MC005 no network: manifest.xml declares no permissions and no source file imports or uses Toybox.Communications Usage: lint_project.py [ROOT] Exit: 0 clean, 1 problems found (printed as file:line: CODE message). """ from __future__ import annotations import re import sys from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path DIAG_FILE = Path("source/util/Diag.mc") BASE32_SECRET_RE = re.compile(r'"[^"\n]*[A-Z2-7]{16,}[^"\n]*"') HEX_SECRET_RE = re.compile(r'"[^"\n]*[0-9a-fA-F]{32,}[^"\n]*"') MARKER_RE = re.compile(r"\b(TODO|FIXME|XXX)\b") PRINTLN_RE = re.compile(r"\bSystem\.println\s*\(") COMMUNICATIONS_RE = re.compile(r"\bToybox\.Communications\b|\bCommunications\.") PERMISSION_RE = re.compile(r" str: return f"{self.path}:{self.line}: {self.code} {self.message}" def strip_line_comment(line: str) -> str: """Drop a trailing // comment (good enough: the rules only look at code).""" in_string = False for i, ch in enumerate(line): if ch == '"' and (i == 0 or line[i - 1] != "\\"): in_string = not in_string elif not in_string and line.startswith("//", i): return line[:i] return line def check_mc(path: Path, rel: Path) -> Iterable[Problem]: """All Monkey C rules for one source or test file.""" lines = path.read_text(encoding="utf-8").splitlines() first = next((line for line in lines if line.strip()), "") if not first.lstrip().startswith("//"): yield Problem(rel, 1, "MC001", "file must start with a // comment header that explains it") in_source = rel.parts[0] == "source" for number, line in enumerate(lines, start=1): code = strip_line_comment(line) if PRINTLN_RE.search(code) and in_source and rel != DIAG_FILE: yield Problem(rel, number, "MC002", "System.println outside source/util/Diag.mc") if in_source and MARKER_RE.search(line): yield Problem(rel, number, "MC003", "unfinished-work marker in source/") secret_like = BASE32_SECRET_RE.search(code) or HEX_SECRET_RE.search(code) if in_source and secret_like and ALLOW_LITERAL not in line: yield Problem(rel, number, "MC004", "secret-looking string literal in source/") if COMMUNICATIONS_RE.search(code): yield Problem(rel, number, "MC005", "network API (Toybox.Communications) is not allowed") def check_manifest(path: Path, rel: Path) -> Iterable[Problem]: for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): if PERMISSION_RE.search(line): yield Problem(rel, number, "MC005", "manifest must not request permissions") def lint(root: Path) -> list[Problem]: problems: list[Problem] = [] for folder in ("source", "test"): for path in sorted((root / folder).rglob("*.mc")): problems += check_mc(path, path.relative_to(root)) manifest = root / "manifest.xml" if manifest.exists(): problems += check_manifest(manifest, manifest.relative_to(root)) else: problems.append(Problem(Path("manifest.xml"), 1, "MC005", "manifest.xml is missing")) return sorted(problems) def main(argv: list[str]) -> int: root = Path(argv[1] if len(argv) > 1 else ".").resolve() problems = lint(root) for problem in problems: print(problem) print(f"lint_project: {len(problems)} problems") return 1 if problems else 0 if __name__ == "__main__": sys.exit(main(sys.argv))