1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
|
#!/usr/bin/env python3
# Author: cirakg & claude ai
# Summary: Project-specific lint rules for the Monkey C sources, the manifest and the file headers.
"""Project-specific lint rules.
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
HDR001 every project file names its author and summarises itself:
"Author: cirakg & claude ai" and "Summary: ..." in its first lines;
JSON files use "author"/"_author" and "description"/"_summary" keys;
PNG images use Author and Description text chunks. Files that a tool
regenerates (GENERATED below) are exempt.
Usage: lint_project.py [ROOT]
Exit: 0 clean, 1 problems found (printed as file:line: CODE message).
"""
from __future__ import annotations
import json
import os
import re
import sys
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
from PIL import Image
AUTHOR = "cirakg & claude ai"
HEADER_LINES = 15
DIAG_FILE = Path("source/util/Diag.mc")
# Folders that hold local tool state or generated output, never project files.
SKIP_DIRS = {".git", ".cache", ".claude", "build", "node_modules", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"}
# Written by a tool that would drop a hand-added header on the next run.
GENERATED = {Path("docker/docs/package-lock.json")}
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"
AUTHOR_RE = re.compile(r"Author:\s+" + re.escape(AUTHOR))
SUMMARY_RE = re.compile(r"Summary:\s+\S")
@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_code(root: Path) -> list[Problem]:
"""The MC rules: Monkey C sources, tests and the manifest."""
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 problems
def project_files(root: Path) -> list[Path]:
"""Every file of the project, skipping tool state and build output."""
files = []
for folder, dirs, names in os.walk(root):
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
files += [Path(folder) / name for name in sorted(names)]
return files
def check_header(path: Path, rel: Path) -> Iterable[Problem]:
"""HDR001 for one file, by file type."""
if rel in GENERATED:
return
suffix = path.suffix.lower()
if suffix == ".png":
with Image.open(path) as img:
info = img.info
if info.get("Author") != AUTHOR or not info.get("Description"):
yield Problem(rel, 1, "HDR001", f"PNG needs Author '{AUTHOR}' and Description text chunks")
return
if suffix == ".json":
data = json.loads(path.read_text(encoding="utf-8"))
meta = data if isinstance(data, dict) else {}
if meta.get("author", meta.get("_author")) != AUTHOR or not meta.get("description", meta.get("_summary")):
yield Problem(rel, 1, "HDR001", 'JSON needs "author"/"_author" and "description"/"_summary" keys')
return
try:
head = "\n".join(path.read_text(encoding="utf-8").splitlines()[:HEADER_LINES])
except UnicodeDecodeError:
yield Problem(rel, 1, "HDR001", "binary file type without a header convention")
return
if not AUTHOR_RE.search(head) or not SUMMARY_RE.search(head):
message = f"needs 'Author: {AUTHOR}' and 'Summary: ...' in its first {HEADER_LINES} lines"
yield Problem(rel, 1, "HDR001", message)
def lint_headers(root: Path) -> list[Problem]:
problems: list[Problem] = []
for path in project_files(root):
problems += check_header(path, path.relative_to(root))
return problems
def lint(root: Path) -> list[Problem]:
return sorted(lint_code(root) + lint_headers(root))
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))
|