From 2071e2beda763137416ac386d4bb953711ec995d Mon Sep 17 00:00:00 2001 From: cirakg Date: Sun, 18 Oct 2026 20:28:09 +0200 Subject: test(tools): add test-log judge, docs and policy checks with tests - ciq_test_report.py: decides pass/fail from Run No Evil output instead of monkeydo's unreliable exit code; writes JUnit XML. - check_docs.py: offline check of relative links and GitHub anchors. - lint_project.py: project rules (headers, no logging outside Diag, no secret-like literals, no network APIs or permissions). - check_budgets.py with budgets.json: perf and memory gates. - make_icon.py: reproducible launcher icon. - 31 pytest tests, including Hypothesis fuzzing of the log parser and integration checks against the real docs and sources; ruff and mypy (strict) clean. Co-Authored-By: Claude Opus 5.5 --- tools/lint_project.py | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tools/lint_project.py (limited to 'tools/lint_project.py') diff --git a/tools/lint_project.py b/tools/lint_project.py new file mode 100644 index 0000000..f11d35e --- /dev/null +++ b/tools/lint_project.py @@ -0,0 +1,112 @@ +#!/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)) -- cgit v1.2.3