Миграция markdown-спецификации ЭНЗС в SDAID-формат

Документ Версия: 1.0 accepted

У команды ЭНЗС уже есть спецификация первого этапа в markdown (Obsidian-хранилище rugrid-devdocs/service-enter/ntr-01/, плюс отдельные спеки в docs/ самого репозитория) — с устоявшимися формулировками, на которые ссылаются задачи, обсуждения с заказчиком, code-review. Переход на SDAID не должен ломать этот язык: SDAID — это структурная упаковка существующего текста, а не его «улучшение». Этот документ фиксирует, что считается структурой (зона ИИ-ассистента), что считается содержанием (зона авторов спеки), и пошаговый рецепт.

Мигрируется только первый этап. Спецификация второго этапа не пишется в markdown и не мигрируется: новые эпики формулируются сразу в SDAID, а сделанное на первом этапе дорабатывается уже в перенесённых SDAID-артефактах. Папка ntr-02/ — не источник миграции.

Документ читается вместе с нотацией (контракты ISdaidDocument, ISdaidDocumentSection, IGlossaryTerm, IFunctionalRequirement и хелперы SdaidAnchors, GlossaryAnchors) — без понимания нотации рецепт ниже применить нельзя.

⟨/⟩ Исходник

1 Сначала план — потом переносMigration_S0_PlanFirst⟨/⟩

Перед миграцией любого блока (vision, эпик, домен, ADR, инфраструктурный контракт) — построить и показать пользователю план переноса:

  • какие исходные файлы и разделы охватывает блок;
  • какие SDAID-классы из них получатся (имя класса, kind-маркер, в какую папку);
  • какие места не очевидно куда переносить — явно выписать как открытые вопросы;
  • какие новые kind-маркеры / расширения нотации потребуются.

Только после апрува пользователя — начинать создавать файлы. Это даёт:

  1. Меньше переделок. Лучше обсудить раскладку 5 минут и переделать 1 файл, чем построить 30 и переразложить.
  2. Видимость решений на границах. Если для какого-то артефакта не находится подходящего kind-маркера или подходящей папки — это нормально, остановись и спроси. Молчаливый fallback на «ISdaidDocument без подтипа» или на ближайшую папку — антипаттерн.
  3. Сохранение «языка команды». План показывает, какие исходные формулировки попадают в какие классы. Если что-то теряется или объединяется — пользователь это видит до того, как стало fait accompli.

Формат плана

Минимум: markdown-таблица с колонками Источник | SDAID-класс | Kind-маркер | Путь в проекте | Открытые вопросы.

Для маленьких блоков (один use-case) — таблицы на 10–20 строк. Для пилотного эпика — таблица на 50–100 строк. Не страшно — это инвестиция в скорость следующих шагов.

Где спрашивать

Любая из следующих ситуаций — остановка + вопрос пользователю:

  • Не нашёлся подходящий kind-маркер в нотации (см. SdaidDocumentKinds.cs + методологию, раздел «Что куда писать / структура папок»).
  • Несколько вариантов размещения (например, сервис относится и к приёму данных из АИС, и к расчёту — где?).
  • Конфликт между источниками (один и тот же артефакт описан в двух md-файлах с разными формулировками — типично для ntr-01/ и ntr-02/).
  • Несоответствие между источником и текущей методологией.

2 Главный принципMigration_S1_Principle⟨/⟩

Преобразовать структуру, не редактируя текст. Команда и заказчик общаются формулировками из спеки. Задача в трекере говорит «реализовать FR-301 — импорт данных из АИС». Если миграция перепишет FR-301 «своими словами» (даже короче или яснее), задача потеряет связь с текстом, обсуждения станут двусмысленными.

Что — структурное (зона ИИ-ассистента)

  • Имена классов: FR_301_AisDataImport, EnergyProfile, S_DocumentPurpose, BP_03_ConsumptionModel.
  • Иерархия секций через Parent => typeof(SomeSection), порядок через Order.
  • Идентификаторы и коды на самих concrete-классах: public static string Code => "FR-301", public static string Name => "Энергопрофиль". На интерфейсах их нет — это была бы либо static abstract (ломает использование интерфейса как type argument, CS8920), либо instance + new()/рефлексия в хелперах (мешает читать спеку).
  • Типизированные ссылки — через прямое обращение к статикам concrete-класса: SdaidAnchors.RefTo<FR_301_X>(FR_301_X.Code), GlossaryAnchors.RefTo<SomeTerm>(SomeTerm.Name), SomeTerm.Definition. Класс упомянут дважды (как type argument и как owner статика) — это то, что ловит компилятор при F2-rename.
  • <summary> XML-doc над классами — служебные, на сайте не отображаются.
  • Структурные обёртки-секции (например, S_Introduction с пустым телом, повторяющая в дереве заголовок «1. Введение» исходника).

Что — содержательное (зона авторов спеки)

  • Name, Description, Body, Definition, Summary, AcceptanceCriteria — всё, что пользователь видит в рендере.
  • Любые prose-абзацы, таблицы (контент ячеек), списки.
  • Опечатки исходника. Оставлять как есть. Это часть исторической записи; правка — отдельное решение команды, не миграции.
  • Markdown-ссылки на старые пути и Obsidian-ссылки ... — оставлять дословно; они «битые» сейчас, починятся когда мигрируем целевой раздел и заменим на nameof(...).

Если содержание не помещается в текущую нотацию

Обсудить с пользователем что делать. Не принимать никаких решений самостоятельно и безмолвно. Если миграционный фрагмент не ложится на существующий контракт (нет подходящего kind-маркера, нет helper-а, нет поля в интерфейсе) — остановиться, сформулировать гэп одним абзацем и вынести пользователю. Возможные варианты решения (расширить нотацию, добавить helper, изменить генератор, пересмотреть структуру) — выбирает пользователь.

Расширить нотацию или генератор, а не перефразировать источник. Когда направление согласовано с пользователем — выполнить расширение, не подгонять текст. Соблазн «упростить» текст чтобы он «влез» в текущую нотацию — антипаттерн: один раз позволил себе перефразировать — и весь язык команды поплыл.

3 Чтение источника и инвентаризацияMigration_S2_ReadFirst⟨/⟩

Прежде чем создать первый класс — прочитать весь исходный документ или папку и составить таблицу артефактов: какие H1/H2/H3-заголовки, какие таблицы, какие ссылки между файлами. Без этой инвентаризации легко:

  • Пропустить раздел (и потом восстанавливать после замечания пользователя).
  • Создать дубликаты (один термин описан и в ntr-01/domain/terms/, и внутри спеки раздела — с разными определениями; нужно решение команды, какой канон).
  • Неправильно расставить иерархию (раздел с подпунктами 4.4.1–4.4.4 — каждый адресуется отдельно).

Где лежит источник

Источник Что это
rugrid-devdocs/service-enter/ntr-01/ спецификация первого этапа: домен, use-cases, UI, роли, отчёты, прототип, интеграция
rugrid-devdocs/mver-module/tech-spec/ тех-спека модуля MVER
rugrid-ntr/docs/*.md точечные спеки в самом репозитории кода (импорт из АИС, модель потребления, безопасность)

ntr-02/ источником не является — второй этап специфицируется сразу в SDAID (см. описание документа).

Что выписать на этапе чтения

  • Все H1/H2/H3 с их прямым текстом — это будущие Name-ы секций.
  • Таблицы и их колонки — будущий формат markdown-таблиц в Body секций.
  • Идентификаторы артефактов: FR-NNN, БП-N, ADR-N — нужны для Code / выбора kind-маркера.
  • Внешние ссылки: на другие md-файлы, на файлы исходников, на enum-ы — пометить как «будущая трассировка через nameof».
  • Obsidian-специфика: wiki-ссылки ..., _assets/-вложения, dataview-блоки — пометить, как переносим (обычно: текст дословно, ссылку чиним позже).
  • Опечатки и стилистические странности — подсветить и оставить.
  • Конфликты: один и тот же термин с разными определениями в разных файлах. Не решать в одиночку — вынести команде.

Когда останавливаться и спрашивать

  • Источник имеет неоднозначность (одно слово в двух местах с разными смыслами).
  • Два файла содержат «одно и то же» с дрейфом формулировок (типично для ntr-01/ ↔ rugrid-ntr/docs/, где спека уточнялась по ходу реализации).
  • Источник ссылается на артефакт, которого ещё нет в новой структуре.

Лучше один вопрос пользователю до начала, чем 30 файлов с silent decisions, которые придётся пересматривать.

4 Маппинг markdown → SDAID-классыMigration_S3_Mapping⟨/⟩

Базовое соответствие

Markdown-конструкция SDAID-конструкция Куда дословный текст
Заглавный # H1 файла Name документа (ISdaidDocument или подтип) в Name
Преамбула под H1 (до первого H2) Description документа в Description
## H2 раздел ISdaidDocumentSection с Parent => typeof(object) заголовок → Name, содержимое → Body
### H3 подраздел ISdaidDocumentSection с Parent => typeof(ParentSection) то же
#### H4 и глубже секция с Parent => typeof(H3Section) то же
Markdown-таблица в Body секции, как есть вся таблица в Body
Список (bullet/numbered) в Body секции, как есть весь список в Body
Code fence в Body, как есть весь блок
> blockquote в Body, как есть весь блок
Mermaid-диаграмма в Body, как есть (генератор рендерит) весь блок

Заголовок секции и нумерация

Markdown-источник часто содержит ручную нумерацию: ### 1.1. Назначение документа. В Name секции префикс «1.1.» не включать — генератор нумерует секции автоматически по Order/иерархии. Name => "Назначение документа" достаточно. Это не перефразирование: «1.1.» — это структура (генератор её восстанавливает), а «Назначение документа» — содержание (оно сохраняется буквально).

Обёрточные секции с пустым телом

Если в источнике под ## H2 сразу идут ### H3 без preamble (например, «## 1. Введение» → сразу «### 1.1.»), создаётся обёрточная секция с Body => "". Это сохраняет иерархию и группировку, не добавляя своего текста.

Маркеры kind документов

Для документа выбирается подтип ISdaidDocument по смыслу:

  • Видение проекта → IVisionDocument
  • Эпик/инициатива → IEpicDocument
  • Use Case (сквозной сценарий пользователя) → IUseCaseDocument
  • Архитектурное решение → IAdrDocument
  • Функциональное требование как отдельный документ → IFunctionalRequirementDocument
  • Страница UI → IPageDocument; переиспользуемый компонент → IUiComponentDocument
  • Доменная сущность → IDomainEntityDocument
  • Обзор доменного модуля → IBoundedContextModuleDocument
  • API-контракт операции (endpoint + command/query + result) → IApiContractDocument
  • Handler (canonical narrative операции) → IHandlerDocument
  • Доменный сервис → IDomainServiceDocument; прикладной сервис уровня реализации → IApplicationServiceDocument
  • State machine → IStateMachineDocument
  • Инфраструктурный контракт → IInfrastructureDocument
  • Батч замечаний/пожеланий → IChangeRequestDocument
  • Обзорный документ подсистемы (карта кода, деплой-единица) → IApplicationDocument

Если ни один из существующих kind-маркеров не подходит — обратиться к пользователю, не решать самостоятельно. Описать документ-источник в один абзац (о чём он, как используется командой, на каком уровне иерархии живёт) и предложить варианты:

  • Использовать ISdaidDocument без подтипа — если документ действительно «просто структурный» и не требует kind-специфичного рендеринга / индекса.
  • Завести новый kind-маркер в нотации — если документ-категория повторяется и заслуживает собственного landing-page по типу.

Решение о направлении принимает пользователь. Безмолвный fallback на «ISdaidDocument без подтипа» — это та же ошибка, что и перефразирование текста.

Функциональные требования: прогрессивная проработка vision

FR-таблица в исходном vision — это в большинстве случаев просто текст: ID, краткая формулировка, приоритет, покрытие. На старте миграции она не материализуется в отдельные классы-требования:

  • Vision-секция группы (5.1, 5.2, …) — обычная ISdaidDocumentSection, в Body лежит дословная markdown-таблица из источника. Ячейка ID — пока plain-текст «FR-301».
  • FR не существует как отдельная сущность, пока команда не решила его проработать.

Материализация = создание класса. Когда команда берётся за конкретный FR:

  1. Создаётся отдельный IFunctionalRequirementDocument-класс (например, FR_301_AisDataImport) в 30_Requirements/. Только теперь у требования есть собственная страница, на которой его можно расписать (секции, AC) и трассировать.
  2. В соответствующей vision-таблице plain-текст «FR-301» заменяется на ссылку на этот документ (через typed-helper).
  3. FR попадает в kind-index требований, в матрицу трассируемости, в auto-linkify.

Зачем так:

  • Vision не «рассыпается». Чтобы прочитать что система должна уметь, читатель открывает Vision и видит ровно те же таблицы, что в исходнике.
  • Проработка видна структурно. Глядя на таблицу, видно сразу: какие ID — текст, какие — кликабельные ссылки. Это карта зрелости проработки.
  • Создание не опережает проработку. Класс заводится тогда, когда есть что записать в его секции.

Антипаттерн: предварительная материализация всех FR

Создать 40 классов-FR на этапе миграции «чтобы все были адресуемые» — это антипаттерн. Все 40 будут пустыми, без описания, без AC, без связей. Vision загромождается, обещание SDAID «структурированная проработка» подменяется «структурированными заглушками».

5 Один источник правдыMigration_S4_SingleSourceOfTruth⟨/⟩

Правило

Каждая единица содержания — один термин, одно требование, один эпик — живёт в одном классе. Если markdown-источник содержит таблицу, которая повторяет описания терминов или требований — таблица в SDAID становится view, ячейки которого подтягиваются из классов-источников через типизированные хелперы.

Глоссарий

Каждый термин — один класс IGlossaryTerm:

  • Name — каноническое отображаемое имя (дословно из источника, на родном языке)
  • Definition — определение (дословно из источника)
  • Aliases, ForbiddenSynonyms — пустые массивы, если не было в источнике

Сводная таблица в Vision-документе или в любом другом месте не дублирует определения — она их рендерит, обращаясь к статикам concrete-классов терминов напрямую:

public string Body => $@"
| Термин | Определение |
|---|---|
| {GlossaryAnchors.RefTo<EnergyProfile>(EnergyProfile.Name)} | {EnergyProfile.Definition} |
";
  • Term.Name, Term.Summary, Term.Definition — public static string на самом классе термина. Доступны без new T() и без рефлексии.
  • GlossaryAnchors.RefTo<T>(string) — строит [displayText](URL страницы термина T). Display-текст передаётся явно.

Класс термина упомянут дважды (как type argument и как owner static-а) — это сознательная избыточность: F2-rename переименовывает оба упоминания компилятором, а читатель видит, откуда приходит каждое значение.

Функциональные требования

Каждый FR-класс несёт public static string Code => "FR-301" (на самом маркере IFunctionalRequirement ничего нет — интерфейс маркер-структурный). Сводная таблица — view, ссылается на FR прямым вызовом:

| {SdaidAnchors.RefTo<FR_301_AisDataImport>(FR_301_AisDataImport.Code)} | <требование дословно> | MVP | <покрытие дословно> |

Если FR-301 переименуют в FR-401 (изменив Code), все таблицы перестроятся автоматически — display-текст приходит из самого класса, а не из строкового литерала, который пришлось бы искать grep-ом.

Анти-паттерн: дублирование текста

Если строка из исходника попала в Body сводной таблицы и одновременно в Body соответствующей детальной секции — это уже дрейф потенциального источника правды. Решение: таблица показывает короткое (Code, Name) через helper; детали (текст требования) — в детальной секции. Если повторяется текст в Name секции и в Body — нормально (заголовок + описание); если повторяется текст в Body двух разных секций — пересмотреть.

7 АнтипаттерныMigration_S6_AntiPatterns⟨/⟩

Эти ошибки реально делались при миграции md-спеки в SDAID и явно отмечались пользователем. Сохранены здесь чтобы не повторять.

❌ Перефразирование «для ясности»

Симптом: В Body секции написано «своими словами» вместо дословной цитаты из источника. Часто появляется как «давайте сделаем пояснее», «уберём повтор», «структурируем».

Цена: Команда теряет язык. Поиск по точной фразе в репо не работает. Заказчик говорит «реализовать FR-301 как написано в спеке» — а в спеке теперь другая формулировка.

Лечение: Каждый раз перед тем как написать prose в Body — открыть источник и скопировать соответствующий фрагмент. Если не получается скопировать буквально — что-то не так с маппингом.

❌ Изобретение разделов и ролей

Симптом: Добавление секции, которой в источнике нет, — «мне показалось логичным выделить».

Цена: Документ показывает читателю несуществующий контракт. Любой, кто откроет источник для сверки, не найдёт этой секции там.

Лечение: Если хочется добавить секцию-обёртку — спросить себя: была ли она в источнике? Если нет — не добавлять. Структура SDAID должна один-в-один отражать структуру источника.

❌ Тихое опускание разделов

Симптом: Пропустил раздел «Глоссарий», потому что термины уже мигрировал в отдельные IGlossaryTerm-классы и подумал «зачем дублировать».

Цена: Документ читается как пересказ, не как перенос. Раздел из источника просто исчезает, и читатель не знает почему.

Лечение: Каждый H2/H3 раздел получает SDAID-секцию. Если он содержательно перекочевал в другое место (как глоссарий → отдельные term-классы), секция всё равно создаётся — её Body становится view, подтягивающий данные из новых источников.

❌ Преждевременная материализация всех FR в классы

Симптом: На этапе миграции каждый FR-NNN из таблицы Vision стал отдельным классом-требованием. Vision наполнился десятками «фантомных» сущностей, по которым никто пока не работал.

Цена: Целостный взгляд на систему заменён грудой пустых страниц-обёрток; на kind-index «Все FR» — десятки пунктов без содержания.

Лечение: На этапе миграции FR-таблица в Vision остаётся обычной markdown-таблицей. Класс заводится только тогда, когда FR действительно прорабатывается.

❌ Row-specific хелперы вместо value-cell хелперов

Симптом: Завёл private static string Row<T>(), формирующий целую строку таблицы.

Цена: Хелпер привязан к конкретному формату таблицы. Захочется добавить колонку — придётся писать Row2<T> или менять интерфейс.

Лечение: Содержимое отдельных ячеек строится прямым обращением к статикам concrete-классов. Структура таблицы (количество колонок, порядок) — markdown в Body, не в хелпере.

❌ Строковый display, когда у типа есть идентификатор

Симптом: SdaidAnchors.RefTo<FR_101_X>("FR-101") — строка рядом с типом.

Цена: Если код FR изменится, строка рядом не обновится — drift. Опечатка в строке — компилятор пропускает.

Лечение: Завести на самом concrete-классе public static string Code => "FR-101" и использовать SdaidAnchors.RefTo<FR_101_X>(FR_101_X.Code).

❌ «Заглушка» вместо настоящего класса термина

Симптом: Создал IGlossaryTerm-класс с Definition, написанным «по смыслу», потому что показалось, что сойдёт.

Цена: Полный набор терминов выглядит готовым, но определения не совпадают с источником ни в одном символе.

Лечение: Либо переносим термин с дословным определением, либо не создаём класс. Лучше отсутствие термина, чем фабрикация.

8 Расширение нотации vs перефразированиеMigration_S7_ExtendDoNotRephrase⟨/⟩

Главный сигнал расширить нотацию — когда ловишь себя на мысли «давайте я тут чуть-чуть переформулирую чтобы влезло». Если есть искушение перефразировать — почти всегда правильный ход: остановиться и расширить нотацию/генератор.

Шаблон расширения

Когда обнаружен очередной разрыв:

  1. Сформулировать гэп в один абзац: что именно не получается выразить.
  2. Вынести пользователю — описать гэп, перечислить возможные варианты решения (новое поле в интерфейсе / новый маркер / новый helper / изменение генератора / иначе), назвать trade-off-ы каждого. Решение о направлении принимает пользователь. Никаких молчаливых «я подумал и решил».
  3. После согласования: добавить минимальное расширение в Rugrid.Sdaid.Notation (новое свойство интерфейса, новый маркер, новый helper).
  4. Если генератор должен диспатчить по этому расширению — добавить ветку в SpecReader / PageGenerator проекта Rugrid.Sdaid.Generator.
  5. Зафиксировать расширение в этом документе (список ниже) — чтобы следующая итерация миграции опиралась на накопленный опыт.

Журнал расширений нотации

Пока пуст. Каждое согласованное расширение добавляется сюда одним блоком: Гэп (что не выразилось) → Расширение (что добавили в нотацию/генератор).

Что уже известно из опыта донорского проекта

Нотация в её текущем виде уже вобрала пять расширений, сделанных на предыдущей миграции: собственный SourcePagePath у секции; маркер IFunctionalRequirement (чтобы FR-секция, а не только FR-документ, попадала в индекс и матрицу трассируемости); Code/Name как статики на concrete-классе вместо строкового display; GlossaryAnchors.RefTo<T> для cross-page ссылок на страницы терминов; разведение sidebar-папок и kind-маркеров (IFolderForKind требует TKind : ISdaidDocument, поэтому глоссарий группируется генератором напрямую, а FR-секции — через faceted kind-index).

Открытый вопрос, унаследованный от донора: обобщить IFolderForKind, чтобы он работал для произвольных маркеров (IRequirement, IGlossaryTerm), либо завести параллельную концепцию IFolderForMarker.

9 Фазы миграцииMigration_S8_Phases⟨/⟩

Не пытаться смигрировать всё разом. Лучше тонкая вертикальная нить, валидирующая методологию end-to-end, потом утолщение по слоям.

Фаза 0: Подготовка

  • Прочитать нотацию (контракты, kind-маркеры, helpers) — без неё не получится.
  • Прочитать этот HowTo + методологию проекта (00_Methodology/Methodology.cs).
  • Прочитать весь markdown-источник и составить инвентарь (см. секцию о чтении источника).
  • Проект Rugrid.Sdaid.Epas.Ntr.csproj и базовая структура папок — уже созданы, Folders.cs с sidebar-папками заведён.

Фаза 1: Vision + Глоссарий + FR

Самая «горизонтальная» часть.

  1. Vision-документ (IVisionDocument) — один на проект, с секциями для каждого H2/H3 исходника. Дословный текст. Второй этап не заводит own-vision: новые эпики дописываются в тот же документ и в 60_Epics/.
  2. Глоссарий — по одному IGlossaryTerm-классу на каждый термин. Если в исходнике несколько мест с определениями и они отличаются — спросить пользователя, какой канон.
  3. Раздел Vision-глоссарий становится view-секцией: ячейки строятся прямыми обращениями GlossaryAnchors.RefTo<T>(T.Name) и T.Definition.
  4. FR-таблицы Vision — секции групп содержат дословные markdown-таблицы из источника. Никаких FR-классов на этом этапе не создаётся (прогрессивная проработка).

После этой фазы генератор показывает полный Vision со всеми разделами, кликабельную FR-сводку, страницы терминов. Это валидация, что нотация подходит проекту.

Фаза 2: Пилотный эпик end-to-end

Выбрать один эпик (желательно самый зрелый, со всеми артефактами: use-case, state-machine, страницы, endpoints, handlers, services, ADR). Перед миграцией построить и согласовать план. Затем мигрировать целиком:

Артефакт Kind-маркер Куда (слой)
Эпик-overview IEpicDocument 60_Epics/<E>/
Use Case IUseCaseDocument (с PrimaryActor) 60_Epics/<E>/UseCases/<UC>/
Endpoint + Command/Query + Result IApiContractDocument 45_Api/Endpoints/<Area>/
Handler (canonical narrative) IHandlerDocument 55_Backend/Handlers/<Area>/
Доменный / прикладной сервис IDomainServiceDocument / IApplicationServiceDocument 55_Backend/Services/<Area>/
State machine IStateMachineDocument 60_Epics/<E>/UseCases/<UC>/StateMachines/
Страница UI IPageDocument 65_Ui/Pages/<Area>/
Локальный R-N эпика IAdrDocument 60_Epics/<E>/Adr/

Что НЕ делаем на этом этапе:

  • Реализационные артефакты не кладём под use-case. Endpoint/handler/страница живут в своих слоях; use-case ссылается на них через nameof().

Цель фазы — нащупать, что ещё не помещается в нотацию (вероятно, всплывут 1–2 новых расширения).

Фаза 3: Domain + остальные эпики + кросс-проектные ADR

  • Domain. Каждая сущность размещается в своём доменном модуле 50_Domain/<Module>/. Сущностей, рождающихся в use-case-е, не бывает. Каждый модуль получает IBoundedContextModuleDocument-overview (BC_<Module>.cs) и IDomainEntityDocument-классы для сущностей и агрегатов.
  • Кросс-проектные ADR — 40_Arch/ADR/, 40_Arch/Tech/<Stack>/.
  • Инфраструктурные контракты — 70_Infrastructure/<Capability>/ (по необходимости).
  • Особенности реализации подсистем — 80_Subsystems/<S>/ (по необходимости).

Циклически по эпикам; после пилота темп предсказуем.

Фаза 4: Зачистка и валидация

  • Проверить, что все разделы каждого исходного md-файла отражены в SDAID (инвентарь из фазы 0 — чеклист).
  • Проверить, что таблицы в источниках стали view-секциями, а не дубликатами.
  • Найти оставшиеся ссылки на старые md-пути и заменить на nameof()-упоминания там, где целевые классы уже есть.
  • Прогнать генератор, открыть сайт, ткнуть в каждый раздел — рендерится ли как ожидалось.

Фаза 5: Сверка с реализацией

Перенос закончен там, где спека совпала с источником. Но источник писался до и во время реализации, поэтому код уже разошёлся со спекой в деталях. Эта фаза находит расхождения и предъявляет их пользователю — и только.

Ничего не синхронизируется молча. Ни спека под код, ни код под спеку: методология прямо запрещает тихую синхронизацию. Результат фазы — список, а не правки.

Что сверяем, блок за блоком:

  • Домен — состав полей сущности из таблиц источника против src/Ntr.Domain (лишние и отсутствующие поля, разошедшиеся типы и обязательность).
  • Методика расчёта — множество methodSectionId в 55_Backend/Calc/ против XML-doc калькуляторов в src/Ntr.Calc (секции только в спеке / только в коде).
  • UI — страницы и компоненты против маршрутов и компонентов src/Ntr.Web (пункты меню, вкладки, claim-и доступа).
  • API и backend — endpoint-ы и хендлеры против контроллеров и CQRS-классов src/.

Каждое расхождение — строка в SPEC_VS_IMPL_ntr-01.md: где в спеке, где в коде, в чём разница, и пусто в колонке решения — её заполняет пользователь. После его решения расхождение либо превращается в правку спеки, либо в задачу на код, либо в открытый вопрос эпика.

Признаки готовности

  • Все Name-ы, Description, Body, Definition — дословные цитаты из исходного md.
  • Все типизированные ссылки — через SdaidAnchors.RefTo<T>(T.Code) / GlossaryAnchors.RefTo<T>(T.Name) / nameof(T). Строковых ссылок с дублированием идентификаторов — нет.
  • Каждый артефакт встречается в одном классе; таблицы — это views.
  • Если в нотации обнаруживались разрывы — они закрыты расширением, не перефразированием, и записаны в журнал расширений.

10 MIGRATION_MAP.md — карта md → csMigration_S85_MigrationMap⟨/⟩

Рядом с этим HowTo лежит MIGRATION_MAP.md — таблица соответствия исходных markdown-файлов (rugrid-devdocs/service-enter/, rugrid-ntr/docs/) и перенесённых SDAID-классов в sdaid/Rugrid.Sdaid.Epas/.

Назначение карты: в исходных файлах реализации (src/) и в задачах долгое время сохраняются ссылки на старые md-пути. После миграции документ живёт по новому пути, а ссылки в потребителях надо переключать. Карта даёт быстрый lookup «куда переехал этот md».

Обязательное требование

Любая миграция md-файла → SDAID-класс ОБЯЗАТЕЛЬНО фиксируется строкой в MIGRATION_MAP.md в той же сессии, до сборки + регенерации сайта.

  • Один md-файл → одна строка в соответствующей секции (эпик / ADR / методология / …).
  • Если переносится несколько md-файлов в один SDAID-класс (объединение) — несколько строк с одним и тем же значением в правой колонке.
  • Если один md-файл разделяется на несколько SDAID-классов — несколько строк с одним и тем же значением в левой колонке.
  • Новая секция верхнего уровня (например, новый эпик, новая категория ADR) — новый заголовок ## … в карте.

Что НЕ делать

  • Не оставлять обновление карты «на потом». Если миграция блока завершена без правки карты — это незаконченная миграция. Текущая сессия — единственная, в которой соответствие md → cs ещё свежо в голове.
  • Не дублировать содержимое карты в других местах. Карта — единственный источник правды для md → cs.
  • Не описывать в карте «причины» переноса — это не журнал миграции, а компактный lookup-индекс. Решения о структуре фиксируются в методологии / документе эпика.

Чеклист на конец миграции блока

Перед dotnet build + регенерацией HTML после миграции блока:

  1. Открыта MIGRATION_MAP.md.
  2. Для каждого нового cs-файла в блоке — есть строка в соответствующей таблице.
  3. Если блок целиком новый (новый эпик / новая категория) — есть отдельный ## …-заголовок над таблицей.

11 Доменные сущности — модуль, DOC_ + класс, SdaidCodeMigration_S86_DomainEntities⟨/⟩

Сводный рецепт переноса доменной модели (описания из ntr-01/domain/*, реальные сущности из src/Ntr.Domain) → 50_Domain/<Module>/<Entity>.cs.

Структура папок и namespace

50_Domain/
  <Module>/
    BC_<Module>.cs            ← IBoundedContextModuleDocument (overview модуля)
    <Entity1>.cs              ← один файл = DOC_<Entity1> + <Entity1> class
    <Entity2>.cs

Namespace: Rugrid.Sdaid.Epas.Ntr.Domain.<Module>. Модуль выбирается по смыслу предметной области; при неоднозначности — вопрос пользователю, не молчаливый выбор.

Один файл на сущность — два класса

Каждый файл <Entity>.cs содержит:

  1. DOC_<Entity> — IDomainEntityDocument, IFolder<NtrDomainFolder>. Свободная проза, никаких обязательных секций. Поля:
    • public static string _refName = "Человеческое имя" — для красивого display в RefTo<DOC_X>().
    • Name, Description, Version, Status, Comments.
    • Entity => typeof(<Entity>) — парный класс-сущность.
    • Произвольный набор public static string <ChunkName> со свободной прозой — каждый становится addressable chunk-ом #chunk-<chunkname>.
  2. <Entity> — сам C#-класс сущности, помечен [DomainEntity]. На каждом FK-property — [Relation]. Остальное — как есть.

Альтернатива: сущность-документ

Когда у сущности пока нет готового C#-класса (спека пишется вперёд реализации) или проза/требования по свойствам важнее, чем структура для tooling — сущность оформляется как документ, без парного [DomainEntity]-класса:

  • Класс-документ помечен IDomainEntityDocument, IFolder<NtrDomainFolder>, Entity => typeof(object) (готового класса нет → генератор не подтягивает авто-структуру/исходник).
  • Секция-абзац верхнего уровня — public static string <Name> = "## Заголовок\n..." (рендерится как chunk с якорем chunk-<name>).
  • Секция-свойство — nested-class с instance-свойством public string Body => "### Заголовок\n\n...". Только такой класс рендерится как раздел с якорем chunk-<ИмяКласса>, на который точно попадает RefTo. ⚠ public static string Name/body НЕ работают: bare-nested-класс — структурный контейнер, а не визуальный раздел.
  • Группировка — контейнер-класс с public string Body => "## Заголовок группы", внутри — секции-свойства.
  • Опц. public static string _refName = "…" на классе секции — текст для RefTo-ссылки (поля с _ генератор не рендерит как контент).
  • Прозу, требования и особенности свойства пишем в одном месте (в его Body), не размазывая по XML-doc + отдельным chunk-ам.

Что когда выбирать. Документ-формат — для сущностей, описываемых спекой вперёд кода. Формат «класс + DOC» — когда нужен реальный [DomainEntity]-класс: сид-данные (<Entity>SeedData), [Relation]-диаграмма, компайл-сейфти nameof(<Entity>.Field).

Что переносить откуда — source-verbatim

Если исходник — C#-класс из src/Ntr.Domain, проза живёт в XML-doc: верхнеуровневый <summary>, <remarks>, per-property <summary>.

Правила переноса:

  • Description документа = первое предложение верхнеуровневого <summary> дословно.
  • На классе остаётся только это первое предложение + per-property XML-docs (они привязаны к свойствам, ценны для IDE-tooling).
  • Длинная проза из <summary>/<remarks> переносится в DOC_ как отдельные chunks. Каждый смысловой блок — своя public static string.
  • Текст переносится дословно. Меняется ТОЛЬКО синтаксис формата:
    • <b>...</b> → **...**
    • <c>...</c> → backticks
    • <see cref="X"/> → backticks `X` (не ссылки — это имена свойств/типов, не doc-артефакты)
  • Не выдумывать разделов «Зачем нужна», «Откуда взялась», «Ключевые правила», если их нет в исходнике. Это главная ловушка миграции доменных сущностей.

Встраивание исходника в прозу

В DOC_ обычно ставится финальный chunk:

public static string Body = $"""
    ## Класс

    {SdaidCode.OfClass<EntityName>()}
    """;
  • SdaidCode.OfClass<T>() — generic, для обычных классов.
  • SdaidCode.OfClass(typeof(StaticHelper)) — не-generic, для статических классов (seed-data, утилиты).
  • SdaidCode.OfMember<T>(nameof(T.X)) — для отдельного члена.

Чеклист на одну сущность

  1. Создать 50_Domain/<Module>/<Entity>.cs.
  2. Скопировать класс сущности из источника, проставить [DomainEntity] и [Relation]. Per-property XML-doc — без изменений.
  3. Из верхнеуровневого <summary> оставить только первое предложение.
  4. Над классом добавить DOC_<Entity> с _refName, Name, Description (verbatim), Entity => typeof(<Entity>), chunks по смысловым блокам, финальный Body со SdaidCode.OfClass<X>().
  5. Если в модуле ещё нет BC_<Module>.cs — создать (без выдуманной прозы).
  6. Обновить MIGRATION_MAP.md.
  7. Сборка + регенерация + визуальная проверка страницы.

Подводные камни

  • init-сеттеры в netstandard2.0 — нужен polyfill IsExternalInit (уже добавлен в _Polyfills.cs); там же stub DateOnly.
  • Static-классы как type argument — SdaidCode.OfClass<SomeSeedData>() не скомпилируется (CS0718). Использовать не-generic перегрузку: SdaidCode.OfClass(typeof(SomeSeedData)).
  • Cross-модульные ссылки в XML-doc на класс из ещё не мигрированного модуля — заменять <see cref="X"/> на <c>X</c>, иначе CS0246.
  • Стейл DLL после миграции — если генератор падает с TargetInvocationException, пересобрать: dotnet build sdaid/Rugrid.Sdaid.Epas.Ntr и запустить генератор с --no-build.

12 Чеклист на старте следующей сессииMigration_S9_NextSessionChecklist⟨/⟩

Когда пользователь скажет «продолжаем миграцию» — выполнить в этом порядке, до первого Edit/Write:

  1. Прочитать нотацию: 15_Documents/SdaidDocument.cs, SdaidDocumentSection.cs, SdaidDocumentKinds.cs, SdaidAnchors.cs, SdaidStructuralLinks.cs, SdaidFolder.cs, SdaidFolderForKind.cs, DomainEntityAttribute.cs; 20_Glossary/GlossaryTerm.cs, GlossaryAnchors.cs; 30_Requirements/IRequirement.cs. Это контракты, ничего без них не делается.
  2. Прочитать этот HowTo целиком.
  3. Прочитать текущее состояние спеки: sdaid/Rugrid.Sdaid.Epas/. Если что-то уже мигрировано — пересмотреть на соответствие правилам этого HowTo.
  4. Если ловится дрейф (перефразированный текст, фабрикованные термины, row-specific хелперы) — поправить до новой работы. Иначе старые ошибки расползутся в новые файлы.
  5. Прочитать источник (rugrid-devdocs/service-enter/, rugrid-ntr/docs/) — что ещё не мигрировано, сверяясь с MIGRATION_MAP.md.
  6. Спросить пользователя: с какого источника продолжаем — vision, конкретный эпик, глоссарий, ADR?
  7. Только после этого начинать миграцию следующего блока по фазам из секции «Фазы миграции».

Признаки что чеклист пропущен

  • Появляется prose в Body, которое не цитируется из источника → перефразирование вернулось.
  • Создаются классы для артефактов, которых нет в источнике → фабрикация.
  • Появляются строковые литералы в RefTo для типов, у которых есть статика Code/Name → пропустил прямое обращение.
  • Таблица оформлена через row-specific helper → пропустил value-cell pattern.

Если поймали — остановиться, перечитать секцию «Антипаттерны», переделать.