summaryrefslogtreecommitdiff
path: root/tools/lint_project.py
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-18 20:28:09 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-18 20:28:09 +0200
commit2071e2beda763137416ac386d4bb953711ec995d (patch)
treebdcf840cb457f8286c1d967410f98972eb99b50a /tools/lint_project.py
parenta9b1dc8bd9354973eabd72f56d82b2eab6f48d56 (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'tools/lint_project.py')
-rw-r--r--tools/lint_project.py112
1 files changed, 112 insertions, 0 deletions
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"<iq:uses-permission\b")
+ALLOW_LITERAL = "lint: allow-literal"
+
+
+@dataclass(frozen=True, order=True)
+class Problem:
+ path: Path
+ line: int
+ code: str
+ message: str
+
+ def __str__(self) -> 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))