- новый файл: имя → содержимое текстом (```-обёртка снимается) или документом - новая папка (виртуальная, пока в ней нет файлов), замена с diff +N/−M, удаление - валидация: имя, ≤ 100 КБ, UTF-8, без NUL; .py — ast.parse с выбором «исправить / всё равно»; .sourcecraft/, .tgit/, ruff.toml из бота не меняются - DraftService: черновик в БД, 24 ч, ≤ 50 изменений; удаление нового файла убирает его из черновика - дерево показывает черновик поверх репозитория: 🆕 / ✏️ / 🗑, размер — как будет после пуша - экран черновика (A/M/D) с «↩️ убрать» Refs #26 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
77 lines
2.2 KiB
Python
77 lines
2.2 KiB
Python
"""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/", ".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
|