#!/usr/bin/env python3 """Codemod: неявные реализации интерфейсов -> явные (§11, вариант A — прод). Только production-код (/tests/ не трогает). Card (DTO) пропускается. Логика: таблицы членов интерфейсов -> маппинг класс -> интерфейсы (включая partial) -> конвертация public-члена с совпавшим именем и сигнатурой в `Тип IFoo.Член`. Ошибочный конверсионный файл откатывается компиляторным циклом (git checkout). Запуск: python scripts/make_explicit.py [--apply] [--file <путь>] """ from __future__ import annotations import re import sys from pathlib import Path REPO = Path(__file__).resolve().parent.parent ROOTS = [REPO / "src" / "core", REPO / "src" / "telegram-service", REPO / "src" / "ai-service", REPO / "src" / "ml-service", REPO / "src" / "storage-service", REPO / "src" / "grpc-hosting"] # файлы-исключения: DTO/модели, члены которых — собственный публичный API EXCLUDE_FILES = {"src/core/Deal.Modules.Cards/Application/Models/Card.cs"} IFACE_DECL = re.compile( r"^\s*(?:public\s+|internal\s+)?(?:partial\s+|sealed\s+|static\s+|unsafe\s+)*interface\s+(\w+)", re.M) CLASS_DECL = re.compile( r"^\s*(?:public\s+|internal\s+)?(?:sealed\s+|abstract\s+|static\s+|partial\s+|unsafe\s+)*" r"class\s+(\w+)(?:<[^>]*>)?[^{;]*:\s*([^{;]+)", re.M) PARTIAL_CLASS = re.compile( r"^\s*(?:public\s+|internal\s+)?(?:sealed\s+|abstract\s+|static\s+|partial\s+|unsafe\s+)*" r"class\s+(\w+)(?:<[^>]*>)?\s*(?::[^{;]+)?\{", re.M) IFNAME = re.compile(r"\bI[A-Z]\w*") METHOD_DECL = re.compile( r"^(?P\s*)(?P(?:public\s+|internal\s+|protected\s+|private\s+|static\s+|" r"virtual\s+|override\s+|sealed\s+|async\s+|new\s+)*)" r"(?P[\w<>\[\],\s.?]+?)\s(?P\w+)\s*(?:<[^>]*>)?\s*\(") PROP_DECL = re.compile( r"^(?P\s*)(?P(?:public\s+|internal\s+|protected\s+|private\s+|static\s+|" r"virtual\s+|override\s+|sealed\s+|new\s+)*)" r"(?P[\w<>\[\],\s.?]+?)\s(?P\w+)\s*\{\s*(?P(?:public\s+)?(?:get|set|init)[^}]*)\}") def src_files() -> list[Path]: out = [] for root in ROOTS: if not root.exists(): continue for p in root.rglob("*.cs"): s = p.as_posix().lower() if "/bin/" in s or "/obj/" in s or "/tests/" in s or ".tests/" in s: continue out.append(p) return out def interface_body(text: str, start: int) -> str: b = text.find("{", start) if b == -1: return "" depth, end = 0, b for i in range(b, len(text)): if text[i] == "{": depth += 1 elif text[i] == "}": depth -= 1 if depth == 0: end = i break return text[b:end] def norm_types(params: str) -> tuple[str, ...]: if not params.strip(): return () out, depth, cur = [], 0, "" for ch in params: if ch in "<([": depth += 1 elif ch in ">)]": depth -= 1 if ch == "," and depth == 0: out.append(cur) cur = "" else: cur += ch out.append(cur) res = [] for raw in out: t = re.sub(r"\s+", " ", raw.strip()) parts = t.rsplit(" ", 1) if len(parts) == 2 and not re.search(r"[<>\[\](),]", parts[1]) and parts[1] not in ("*", "&"): t = parts[0] res.append(t.replace("?", "").replace(" ", "")) return tuple(res) def collect_interfaces(files: list[Path]) -> dict[str, dict[str, dict]]: tables: dict[str, dict[str, dict]] = {} for p in files: text = p.read_text(encoding="utf-8") for m in IFACE_DECL.finditer(text): name = m.group(1) body = interface_body(text, m.start()) lines = body.splitlines() i = 0 while i < len(lines): line = lines[i] mm = METHOD_DECL.match(line) if mm and "(" in line: # сигнатура может быть многострочной (§7: параметры на отдельных строках) sig_text = line while sig_text.count("(") > sig_text.count(")") and i + 1 < len(lines): i += 1 sig_text += " " + lines[i].strip() if sig_text.count("(") > sig_text.count(")"): i += 1 continue params = sig_text.split("(", 1)[1].rsplit(")", 1)[0] tables.setdefault(name, {})[mm.group("name")] = {"kind": "method", "sig": norm_types(params)} i += 1 continue pm = PROP_DECL.match(line) if pm: tables.setdefault(name, {})[pm.group("name")] = { "kind": "prop", "sig": pm.group("type").replace("?", "").replace(" ", "")} i += 1 return tables def collect_class_ifaces(files: list[Path]) -> dict[str, set[str]]: result: dict[str, set[str]] = {} for p in files: rel = p.relative_to(REPO).as_posix() if rel in EXCLUDE_FILES: continue text = p.read_text(encoding="utf-8") for m in CLASS_DECL.finditer(text): ifaces = set(IFNAME.findall(m.group(2))) if ifaces: result.setdefault(m.group(1), set()).update(ifaces) return result def convert_file(path: Path, cls_ifaces: dict[str, set[str]], itables: dict[str, dict[str, dict]]): text = path.read_text(encoding="utf-8") lines = text.splitlines(keepends=True) local: set[str] = set() for m in PARTIAL_CLASS.finditer(text): if m.group(1) in cls_ifaces: local.add(m.group(1)) if not local: return 0, None, [] allowed = {i for cls in local for i in cls_ifaces[cls] if i in itables} changed, notes = 0, [] i = 0 while i < len(lines): line = lines[i] stripped = line.lstrip() if stripped.startswith("//") or stripped.startswith("#"): i += 1 continue mm = METHOD_DECL.match(line.rstrip("\r\n")) pm = None if mm else PROP_DECL.match(line.rstrip("\r\n")) if not mm and not pm: i += 1 continue kind = "method" if mm else "prop" decl = mm or pm mods = decl.group("mods") if "static" in mods or "public" not in mods: i += 1 continue name = decl.group("name") if kind == "method": sig_text, j = line, i while sig_text.count("(") > sig_text.count(")") and j + 1 < len(lines): j += 1 sig_text += lines[j] if "(" not in sig_text: i += 1 continue sig = norm_types(sig_text.split("(", 1)[1].rsplit(")", 1)[0]) else: sig = decl.group("type").replace("?", "").replace(" ", "") j = i cands = [ifc for ifc in allowed if itables[ifc].get(name, {}).get("kind") == kind and itables[ifc][name]["sig"] == sig] if not cands: i += 1 continue ifc = cands[0] head = line.rstrip("\r\n") eol = line[len(head):] # убрать модификаторы (оставить отступ), затем квалифицировать имя new_head = re.sub(r"^(\s*)(?:public\s+|internal\s+|protected\s+|private\s+|virtual\s+|" r"override\s+|sealed\s+|new\s+)+", r"\1", head) m2 = re.search(r"\s" + re.escape(name) + r"(?![\w])", new_head) if not m2: i += 1 continue new_head = new_head[:m2.start()] + " " + ifc + "." + name + new_head[m2.end():] lines[i] = new_head + eol changed += 1 notes.append(f"{path.relative_to(REPO).as_posix()}:{i + 1} -> {ifc}.{name}") i = j + 1 return changed, ("".join(lines) if changed else None), notes def main() -> int: apply = "--apply" in sys.argv only = sys.argv[sys.argv.index("--file") + 1] if "--file" in sys.argv else None files = src_files() itables = collect_interfaces(files) cls_ifaces = collect_class_ifaces(files) total, per_file = 0, 0 for p in files: if only and p.as_posix() != only: continue changed, new_text, notes = convert_file(p, cls_ifaces, itables) if not changed: continue total += changed per_file += 1 if apply and new_text is not None: p.write_bytes(new_text.replace("\r\n", "\n").encode("utf-8")) if not apply: for n in notes[:3]: print(" ", n) print(f"{'apply' if apply else 'dry-run'}: конверсий {total} в {per_file} файлах") return 0 if __name__ == "__main__": raise SystemExit(main())