summaryrefslogtreecommitdiff
path: root/tools/lint_project.py
blob: c1c225a5834d0dbe4b5aed7a496c907450f05b22 (plain) (blame)
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
181
182
183
184
185
186
187
188
189
190
#!/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))