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 зелёные.
235 lines
9.0 KiB
Python
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())
|