Перевести реализации интерфейсов на явные (§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 зелёные.
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user