"""Checks for names and file contents before anything goes into a draft (SPEC C-10, C-12, C-16). No code runs here.""" import ast import difflib import re from dataclasses import dataclass MAX_FILE_BYTES = 100 * 1024 NAME = re.compile(r"^[A-Za-z0-9._-]{1,100}$") # Bot users can't touch the CI of the uploads repo or its helper scripts (SPEC C-16, ADR-014) PROTECTED_PREFIXES = (".sourcecraft/", ".gitea/", ".tgit/", ".git/") PROTECTED_FILES = {"ruff.toml"} _FENCE = re.compile(r"^\s*```[\w+-]*[ \t]*\n(.*?)\n?```\s*$", re.DOTALL) class ValidationError(ValueError): pass @dataclass(frozen=True) class SyntaxIssue: line: int message: str def check_name(name: str) -> str: name = name.strip() if not NAME.match(name) or name in {".", ".."}: raise ValidationError("name") return name def is_protected(path: str) -> bool: return path in PROTECTED_FILES or any(path.startswith(p) or path + "/" == p for p in PROTECTED_PREFIXES) def join(directory: str, name: str) -> str: return f"{directory}/{name}" if directory else name def strip_fence(text: str) -> str: """Code pasted as a Telegram message often comes wrapped in ``` — keep only what's inside.""" match = _FENCE.match(text) return match.group(1) if match else text def check_content(data: bytes) -> str: """Size, encoding, NUL bytes. Returns the decoded text.""" if len(data) > MAX_FILE_BYTES: raise ValidationError("too_big") if b"\0" in data: raise ValidationError("binary") try: return data.decode("utf-8") except UnicodeDecodeError: raise ValidationError("encoding") from None def python_syntax(path: str, text: str) -> SyntaxIssue | None: if not path.endswith(".py"): return None try: ast.parse(text, filename=path) except SyntaxError as e: return SyntaxIssue(line=e.lineno or 0, message=e.msg) return None def diff_stat(old: str, new: str) -> tuple[int, int]: added = removed = 0 for line in difflib.ndiff(old.splitlines(), new.splitlines()): if line.startswith("+ "): added += 1 elif line.startswith("- "): removed += 1 return added, removed def check_message(message: str) -> str: message = message.strip() if not 1 <= len(message) <= 200 or len(message.splitlines()[0]) > 72: raise ValidationError("message") return message