From dde86fd63cd416804de18741b1a0e7900db58a7b Mon Sep 17 00:00:00 2001 From: Rustam Khalimov Date: Fri, 11 Sep 2026 18:19:46 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20codemod=20=D0=B4=D0=B5=D0=B4=D1=83=D0=BF=D0=BB=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=D1=86=D0=B8=D0=B8=20summary=20=D0=B8=20=D1=83?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D1=82=D1=8C=20=D0=B1=D1=8D=D0=BA?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/dedup_summary_inheritdoc.py (dry-run не нашёл простых дублей summary в продакшне — реализации уже на ); закрыт TD-SETTINGS-UI, DEFERRED с обоснованием TD-CARD-MERGE/TD-VIRT/TD-STORE-ATTACH/TD-SOURCE-CONTACTS; игнор __pycache__. --- .gitignore | 4 + backlog.md | 12 +- .../_slim_comments.cpython-312.pyc | Bin 10715 -> 0 bytes scripts/dedup_summary_inheritdoc.py | 190 ++++++++++++++++++ 4 files changed, 200 insertions(+), 6 deletions(-) delete mode 100644 scripts/__pycache__/_slim_comments.cpython-312.pyc create mode 100644 scripts/dedup_summary_inheritdoc.py diff --git a/.gitignore b/.gitignore index bd1235b..8466703 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ deploy/certs/ # === Рантайм-данные (БД, объектное хранилище, ключи шифрования) === archive/leadradar-legacy/data/ src/core/Deal.Api/data/ + +# Python +__pycache__/ +*.pyc diff --git a/backlog.md b/backlog.md index 016a19d..e6f04ad 100644 --- a/backlog.md +++ b/backlog.md @@ -14,9 +14,9 @@ | BL-TG-MULTI | Мультиаккаунтность Telegram (сейчас 1 аккаунт на тенант) | ТЗ §12 | P2 | DEFERRED | | BL-ML-EXP | Экспорт/импорт ML-моделей (перенос «мозгов» между инстансами) | обсуждение этапа 12 | P3 | DEFERRED (решено не делать; вернуться при SaaS-масштабе) | | BL-RECLASS-SSE | **Сделано (2026-09-11):** пакетная переклассификация отдаёт промежуточный прогресс через SSE `cards_reclassified` (`{progress:true,done,total,moved,kept,trashed,skipped}`) и финальное событие (`{progress:false,reclassified,moved}`); `CardReclassifier.ReclassifyInboxAsync` принимает `IProgress`; в UI — индикатор `done/total` в шапке «Неразобранного» | этап 12, D | P3 | DONE | -| TD-CARD-MERGE | Полное слияние внутренних DTO карточки в единый `CardDto` (наружу уже единый) | этап 9/11 | P3 | TECHDEBT | +| TD-CARD-MERGE | Полное слияние внутренних DTO карточки в единый `CardDto`. **Решение (2026-09-11): DEFERRED.** Наружный контракт единый; внутренние DTO (read/write/DB/patch) намеренно разделены по слоям, слияние — риск без пользы | этап 9/11 | P3 | DEFERRED | | TD-PROTO-COMMENTS | **Сделано (2026-09-11):** из комментариев убраны ссылки на процесс/прототип (`Task/Ruling/этап/python L…/main.py/прототип/LEADRADAR_*`), удалены блоки ``, `` сжаты до короткой фразы; `//`-комментарии со ссылками удалены, в `.proto` — тоже. Строк комментариев 27 210 → ~19 100 | запрос владельца 2026-09-11 | P2 | DONE | -| TD-COMMENTS-IFACE | Привести код к правилам код-стайла (`docs/spec/Код-стайл-Дейл.md`). **Сделано (2026-09-11):** (1) `` только блочно — исправлено 5286 шт. в 833 файлах; (2) комментарии только на public/protected — понижено 2028 XML-доков с private/internal (359 файлов). **Осталось:** (3) не дублировать `` интерфейса в реализации (нужен Roslyn-анализ); (4) явная реализация интерфейсов там, где возможно (61 интерфейс, точечный ревью). Скрипты: `scripts/fix_summary_blocks.py`, `scripts/fix_private_docs.py`. Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md` | запрос владельца 2026-09-11 | P2 | TECHDEBT (1,2 — DONE; 3,4 — BACKLOG) | +| TD-COMMENTS-IFACE | Привести код к правилам код-стайла (`docs/spec/Код-стайл-Дейл.md`). **Сделано (2026-09-11):** (1) `` только блочно — исправлено 5286 шт. в 833 файлах; (2) комментарии только на public/protected — понижено 2028 XML-доков с private/internal (359 файлов); (3) codemod `scripts/dedup_summary_inheritdoc.py` (dry-run) — в продакшн-коде простых дублей `` уже нет (интерфейсные реализации используют ``). **Осталось:** блоки с ``+дублирующим summary (нужен Roslyn) и (4) явная реализация интерфейсов (61 интерфейс, точечный ревью). Скрипты: `scripts/fix_summary_blocks.py`, `scripts/fix_private_docs.py`, `scripts/dedup_summary_inheritdoc.py`. Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md` | запрос владельца 2026-09-11 | P2 | TECHDEBT (1,2 — DONE; 3 — проверено codemod'ом; 4 — BACKLOG) | | TD-STYLE-ANALYZERS | Остаток мягких правил код-стайла: `var` для встроенных/неочевидных типов (1529, сейчас `silent`), дедупликация ``→`` (Roslyn), решение по переводам строк (`.editorconfig` = CRLF, фактически 231 CRLF / 697 LF). Уже закрыто в `.editorconfig` (+build-проверка): запрет `this.` и именование приватных полей (`_camelCase`; `const`/`static readonly` — Pascal). Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md` | аудит 2026-09-11 | P3 | BACKLOG | ## 2. Инфраструктура и эксплуатация @@ -51,16 +51,16 @@ | ID | Пункт | Источник | Приоритет | Статус | |---|---|---|---|---| -| TD-SETTINGS-UI | Вынос оставшихся вкладок `SettingsView` в отдельные компоненты (частично сделано) | ревью 2026-09-08 | P3 | TECHDEBT | -| TD-VIRT | Полная виртуализация длинных колонок (сейчас — прогрессивный рендер «Показать ещё») | ревью, этап 12 | P3 | TECHDEBT | +| TD-SETTINGS-UI | Вынос вкладок `SettingsView` в компоненты. **Сделано (2026-09-11):** `SettingsView.vue` — только набор вкладок/QR-опрос, все 10 вкладок — отдельные компоненты (`components/settings/*`) | ревью 2026-09-08 | P3 | DONE | +| TD-VIRT | Полная виртуализация длинных колонок. **Решение (2026-09-11): DEFERRED** — прогрессивный рендер «Показать ещё» покрывает текущие объёмы; виртуализация — при росте списков | ревью, этап 12 | P3 | DEFERRED | | TD-SSE-DEAD | **Сделано (2026-09-11):** мёртвые SSE-ветки фронта `boards_changed`/`pipeline_stats` удалены из `store/lifecycle.js` (core их не публикует) | этап 12, E | P3 | DONE | | TD-DBL-CLICK | **Сделано (2026-09-11):** перезагрузка доски при batch-переклассификации коалесцируется `scheduleBoardReload()` (ответ + SSE → один запрос) | этап 12, E | P3 | DONE | | TD-TEST-HARNESS | Историческая гонка `FreeTcpPort` — устранена; следить за новыми хост-хелперами | этап 12, E | P3 | TECHDEBT | | TD-OLD-DOCS | Исторические доки несут старые термины под пометками. **Проверено (2026-09-11):** `docs/superpowers/plans/*` и старые `docs/architecture/2026-09-0*` имеют шапку «Исторический документ»; переписывать не нужно | docs sweep | P3 | DONE | | TD-SOURCE-PROVIDER | Провайдеры содержимого источников. **Сделано (2026-09-11):** `TelegramSourceContentProvider` + `ReadSource` RPC + `GET /api/cards/{id}/source` + UI «Обновить из источника». Осталось: провайдеры прочих источников по мере появления | generic source 2026-09-11 | P2 | TECHDEBT | -| TD-STORE-ATTACH | Выгрузка вложений источника в Storage-сервис адаптером. **Решение (2026-09-11): медиа-посты Telegram пропускаем** — извлечение/выгрузка не делаются. Остались на будущее: вложения прочих источников (файл/диск/таблица) и `ISourceContentProvider` для remote-просмотра | generic source 2026-09-11 | P3 | BACKLOG | +| TD-STORE-ATTACH | Выгрузка вложений источника в Storage-сервис адаптером. **Решение (2026-09-11): медиа-посты Telegram пропускаем** — извлечение/выгрузка не делаются; вложений у прочих источников пока нет — **DEFERRED** (контракт `DataRef` готов, включается при появлении такого источника) | generic source 2026-09-11 | P3 | DEFERRED | | TD-TG-CORE-SPLIT | Перенос оставшейся Telegram-специфики ядра в telegram-сервис. **Закрыто (2026-09-11): не требуется.** Задача «дашборды/карточки не знают о Telegram» решена generic-контрактом источника; оставшиеся `TelegramStore`/`Dialogs`/`TgMessages`/Discovery — это состояние тенанта (ядро — владелец данных, telegram-service — stateless-шлюз), перенос отдал бы шлюзу доступ к схеме тенанта | generic source 2026-09-11 | — | CLOSED | -| TD-SOURCE-CONTACTS | Квалификатор контактов знает форматы профилей (t.me/`@handle`) — вынести в расширяемые правила источников | generic source 2026-09-11 | P3 | BACKLOG | +| TD-SOURCE-CONTACTS | Квалификатор контактов знает форматы профилей (t.me/`@handle`). **Решение (2026-09-11): DEFERRED** — форматы стабильны, расширяемость под источник добавляется при конкретной потребности | generic source 2026-09-11 | P3 | DEFERRED | | TD-APIMAP-COUNT | Ручной подсчёт числа ручек в `api-map`. **Сделано (2026-09-11):** сверил счётчики §3.1–§3.8 с фактическими строками (рассинхрон §3.5 — 14→15 из-за `GET /cards/{id}/source`); в §3 добавлено правило обновлять счётчики | docs sweep | P3 | DONE | ## 6. Manual-проверки (нужны внешние условия) diff --git a/scripts/__pycache__/_slim_comments.cpython-312.pyc b/scripts/__pycache__/_slim_comments.cpython-312.pyc deleted file mode 100644 index 843531d144012dadda2193a3a98fe07c77450ce1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10715 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 Wr9SBUSh3^2lKlQDpsmao7y7@5s!|aE diff --git a/scripts/dedup_summary_inheritdoc.py b/scripts/dedup_summary_inheritdoc.py new file mode 100644 index 0000000..2cc5769 --- /dev/null +++ b/scripts/dedup_summary_inheritdoc.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Заменить дублирующий реализации на (код-стайл Дейла). + +Консервативно: + * собираются тексты членов интерфейсов (блоки, состоящие только из ); + * в типах с интерфейсной базой такой же по тексту блок-док заменяется на `/// `; + * блоки с ///... и summary самих типов не трогаются. + +Запуск: python scripts/dedup_summary_inheritdoc.py [--apply] +Без --apply — dry-run со счётчиками. +""" +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", +] + +INTERFACE_RE = re.compile( + r"^\s*(?:public|internal|protected|private)?\s*" + r"(?:static\s+|sealed\s+|abstract\s+|partial\s+|unsafe\s+)*interface\s+\w+" +) +TYPE_BASE_RE = re.compile( + r"^\s*(?:public|internal|protected|private)?\s*" + r"(?:static\s+|sealed\s+|abstract\s+|partial\s+|unsafe\s+)*" + r"(?:class|struct|record)\s+\w+[^{;]*:\s*(.+?)(?:\{|$)" +) +IFACE_NAME_RE = re.compile(r"\bI[A-Z]\w*") +EXTRA_TAG_RE = re.compile(r"<(?:param|typeparam|returns|remarks|exception|example|value)\b", re.IGNORECASE) +SUMMARY_OPEN_RE = re.compile(r"", re.IGNORECASE) +SUMMARY_CLOSE_RE = re.compile(r"", re.IGNORECASE) + + +def iter_files() -> list[Path]: + files: list[Path] = [] + for root in ROOTS: + if not root.exists(): + continue + for path in root.rglob("*.cs"): + parts = set(path.parts) + if "obj" in parts or "bin" in parts: + continue + if "tests" in path.as_posix().lower(): + continue + files.append(path) + return sorted(files) + + +def normalize(block: list[str]) -> str: + text = " ".join(line.strip().lstrip("/").strip() for line in block) + text = SUMMARY_CLOSE_RE.sub("", SUMMARY_OPEN_RE.sub("", text)) + return re.sub(r"\s+", " ", text).strip() + + +def is_summary_only(block: list[str]) -> bool: + joined = "\n".join(block) + return ( + bool(SUMMARY_OPEN_RE.search(joined)) + and bool(SUMMARY_CLOSE_RE.search(joined)) + and not EXTRA_TAG_RE.search(joined) + ) + + +def depth_snapshot(lines: list[str]) -> list[int]: + """Глубина фигурных скобок перед каждой строкой (приблизительно, без строк/комментариев).""" + depths: list[int] = [] + depth = 0 + for line in lines: + depths.append(depth) + depth += line.count("{") - line.count("}") + return depths + + +def type_scopes(lines: list[str]): + """Список (start_line, end_line, kind) для интерфейсов и типов с интерфейсной базой. + + kind: "interface" | "type". Границы — по фигурным скобкам объявления. + """ + depths = depth_snapshot(lines) + scopes: list[tuple[int, int, str]] = [] + stack: list[tuple[int, str, int]] = [] # (depth, kind, start_line) + for idx, line in enumerate(lines): + depth_before = depths[idx] + if INTERFACE_RE.match(line): + stack.append((depth_before, "interface", idx)) + else: + m = TYPE_BASE_RE.match(line) + if m and IFACE_NAME_RE.search(m.group(1)): + stack.append((depth_before, "type", idx)) + # закрыть блоки на строке закрывающей скобки (глубина после неё возвращается к уровню объявления) + while ( + stack + and depths[idx] <= stack[-1][0] + and lines[idx].strip().startswith("}") + and idx > stack[-1][2] + ): + depth, kind, start = stack.pop() + scopes.append((start, idx, kind)) + for depth, kind, start in stack: + scopes.append((start, len(lines) - 1, kind)) + return scopes + + +def containing_kinds(scope_kinds: list[tuple[int, int, str]], line_idx: int, depth: int) -> set[str]: + kinds: set[str] = set() + for start, end, kind in scope_kinds: + if start <= line_idx <= end and start != line_idx: + kinds.add(kind) + return kinds + + +def collect_interface_summaries(files: list[Path]) -> set[str]: + summaries: set[str] = set() + for path in files: + lines = path.read_text(encoding="utf-8").splitlines() + depths = depth_snapshot(lines) + scopes = type_scopes(lines) + idx = 0 + while idx < len(lines): + if not lines[idx].lstrip().startswith("///"): + idx += 1 + continue + start = idx + while idx < len(lines) and lines[idx].lstrip().startswith("///"): + idx += 1 + block = lines[start:idx] + kinds = containing_kinds(scopes, start, depths[start]) + if "interface" in kinds and is_summary_only(block): + summaries.add(normalize(block)) + return summaries + + +def process_file(path: Path, iface_summaries: set[str]) -> tuple[int, list[str]]: + lines = path.read_text(encoding="utf-8").splitlines() + depths = depth_snapshot(lines) + scopes = type_scopes(lines) + result: list[str] = [] + replaced = 0 + idx = 0 + while idx < len(lines): + if not lines[idx].lstrip().startswith("///"): + result.append(lines[idx]) + idx += 1 + continue + start = idx + while idx < len(lines) and lines[idx].lstrip().startswith("///"): + idx += 1 + block = lines[start:idx] + kinds = containing_kinds(scopes, start, depths[start]) + if "type" in kinds and is_summary_only(block) and normalize(block) in iface_summaries: + indent = block[0][: len(block[0]) - len(block[0].lstrip())] + result.append(f"{indent}/// ") + replaced += 1 + else: + result.extend(block) + return replaced, result + + +def main() -> int: + apply = "--apply" in sys.argv + files = iter_files() + iface_summaries = collect_interface_summaries(files) + total = 0 + changed = 0 + for path in files: + replaced, new_lines = process_file(path, iface_summaries) + if not replaced: + continue + total += replaced + changed += 1 + if apply: + original = path.read_text(encoding="utf-8") + text = "\n".join(new_lines) + ("\n" if original.endswith("\n") else "") + path.write_text(text, encoding="utf-8") + mode = "применено" if apply else "dry-run" + print(f"{mode}: замен {total} в {changed} файлах (интерфейсных summary: {len(iface_summaries)})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())