Files
Deal/scripts/make_explicit.py
T
Rustam Khalimov 206d61068a Перевести реализации интерфейсов на явные (§11, вариант A)
Codemod scripts/make_explicit.py: 161 член в 30 прод-файлах конвертирован в
вид "Тип IFoo.Член" (частичные классы и многострочные сигнатуры учтены; Card и
ICard-семейство — DTO, оставлены implicit). Потребители, дёргавшие классы
напрямую, перетипизированы на интерфейсы: 8 мест в проде (самовызовы через
((ISessionClient)this), снят дефолт параметра в явной реализации) и 17 тестовых
файлов (поля, tuple-деконструкции, var/target-typed new). Build 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
2026-09-11 20:16:06 +03:00

235 lines
9.0 KiB
Python

#!/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<indent>\s*)(?P<mods>(?:public\s+|internal\s+|protected\s+|private\s+|static\s+|"
r"virtual\s+|override\s+|sealed\s+|async\s+|new\s+)*)"
r"(?P<ret>[\w<>\[\],\s.?]+?)\s(?P<name>\w+)\s*(?:<[^>]*>)?\s*\(")
PROP_DECL = re.compile(
r"^(?P<indent>\s*)(?P<mods>(?:public\s+|internal\s+|protected\s+|private\s+|static\s+|"
r"virtual\s+|override\s+|sealed\s+|new\s+)*)"
r"(?P<type>[\w<>\[\],\s.?]+?)\s(?P<name>\w+)\s*\{\s*(?P<accessors>(?: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())