aboutsummaryrefslogtreecommitdiff
path: root/tools/lint_project.py
blob: f11d35ee6fbceb746c48ae11e310bbd6189da8f2 (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
#!/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))