Миграция markdown-спецификации ЭНЗС в SDAID-формат
У команды ЭНЗС уже есть спецификация первого этапа в 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-маркеры / расширения нотации потребуются.
Только после апрува пользователя — начинать создавать файлы. Это даёт:
- Меньше переделок. Лучше обсудить раскладку 5 минут и переделать 1 файл, чем построить 30 и переразложить.
- Видимость решений на границах. Если для какого-то артефакта не находится подходящего kind-маркера или подходящей папки — это нормально, остановись и спроси. Молчаливый fallback на «
ISdaidDocumentбез подтипа» или на ближайшую папку — антипаттерн. - Сохранение «языка команды». План показывает, какие исходные формулировки попадают в какие классы. Если что-то теряется или объединяется — пользователь это видит до того, как стало 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:
- Создаётся отдельный
IFunctionalRequirementDocument-класс (например,FR_301_AisDataImport) в30_Requirements/. Только теперь у требования есть собственная страница, на которой его можно расписать (секции, AC) и трассировать. - В соответствующей vision-таблице plain-текст «FR-301» заменяется на ссылку на этот документ (через typed-helper).
- 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 двух разных секций — пересмотреть.
6 Типизированные ссылкиMigration_S5_TypedLinks⟨/⟩
Когда какой helper
| Сценарий | Вызов | Что возвращает |
|---|---|---|
| Ссылка на термин с его каноническим именем | GlossaryAnchors.RefTo<T>(T.Name) |
[Термин](URL) |
| Ссылка на термин со склонённым display-текстом | GlossaryAnchors.RefTo<T>("термина") |
[термина](URL) |
| Вставить определение термина как значение | T.Definition |
текст определения как string |
| Ссылка на FR-секцию с её кодом | SdaidAnchors.RefTo<FR_301_X>(FR_301_X.Code) |
[FR-301](#section-anchor) |
| Ссылка на произвольную секцию того же документа | SdaidAnchors.RefTo<T>("text") |
[text](#section-anchor) |
| Ссылка на другой документ / chunk | SdaidAnchors.RefTo<T>() / SdaidAnchors.ChunkRefTo<TDoc>(nameof(TDoc.Chunk), "текст") |
cross-page ссылка |
| Ссылка на значение enum | SdaidAnchors.RefToEnum(SomeEnum.Value) |
ссылка на якорь значения |
| Mermaid-click на секцию | SdaidAnchors.MermaidClickTo<T>("node") |
директива click ... |
| Голый anchor-slug секции (для кастомной разметки) | SdaidAnchors.AnchorOf<T>() |
section-... без # |
| Упоминание любого артефакта в прозе | nameof(SomeType) |
display = TypeName, link через auto-linkify |
| Встроить исходник класса | SdaidCode.OfClass<T>() / SdaidCode.OfClass(typeof(T)) |
code-block в рендере |
| Встроить HTML-прототип | SdaidHtml.IframeOf<TDoc>("file.html") |
iframe в рендере |
Почему класс упомянут дважды
В SdaidAnchors.RefTo<FR_301_X>(FR_301_X.Code) имя класса встречается и как type argument, и как owner static-поля. Это сознательная избыточность:
- Type argument нужен генератору для построения URL/anchor.
FR_301_X.Code— обращение к идентификатору на самом классе. Не строковый литерал.
F2-rename переименовывает оба упоминания одним движением. Опечатка ловится компилятором. Скрыть второе упоминание за дженериком нельзя без рефлексии или static abstract, оба варианта отвергнуты: рефлексия мешает ИИ читать спеку, static abstract блокирует использование интерфейса как type argument в IFolderForKind<TKind> (CS8920).
Когда строковый display оправдан
- Склонение слова в прозе.
- Ссылка с пояснением, не равным
Name:SdaidAnchors.RefTo<ADR_017_X>("решение о вынесении CSS"). - Mermaid-node, чьё имя на диаграмме отличается от
Nameцелевой секции.
Если для нужного kind ещё нет статического идентификатора
Завести public static string Code => "…" или public static string Name => "…" на самом concrete-классе (kind-маркер на интерфейсе не нужен) и обращаться напрямую.
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⟨/⟩
Главный сигнал расширить нотацию — когда ловишь себя на мысли «давайте я тут чуть-чуть переформулирую чтобы влезло». Если есть искушение перефразировать — почти всегда правильный ход: остановиться и расширить нотацию/генератор.
Шаблон расширения
Когда обнаружен очередной разрыв:
- Сформулировать гэп в один абзац: что именно не получается выразить.
- Вынести пользователю — описать гэп, перечислить возможные варианты решения (новое поле в интерфейсе / новый маркер / новый helper / изменение генератора / иначе), назвать trade-off-ы каждого. Решение о направлении принимает пользователь. Никаких молчаливых «я подумал и решил».
- После согласования: добавить минимальное расширение в
Rugrid.Sdaid.Notation(новое свойство интерфейса, новый маркер, новый helper). - Если генератор должен диспатчить по этому расширению — добавить ветку в
SpecReader/PageGeneratorпроектаRugrid.Sdaid.Generator. - Зафиксировать расширение в этом документе (список ниже) — чтобы следующая итерация миграции опиралась на накопленный опыт.
Журнал расширений нотации
Пока пуст. Каждое согласованное расширение добавляется сюда одним блоком: Гэп (что не выразилось) → Расширение (что добавили в нотацию/генератор).
Что уже известно из опыта донорского проекта
Нотация в её текущем виде уже вобрала пять расширений, сделанных на предыдущей миграции: собственный 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
Самая «горизонтальная» часть.
- Vision-документ (
IVisionDocument) — один на проект, с секциями для каждого H2/H3 исходника. Дословный текст. Второй этап не заводит own-vision: новые эпики дописываются в тот же документ и в60_Epics/. - Глоссарий — по одному
IGlossaryTerm-классу на каждый термин. Если в исходнике несколько мест с определениями и они отличаются — спросить пользователя, какой канон. - Раздел Vision-глоссарий становится view-секцией: ячейки строятся прямыми обращениями
GlossaryAnchors.RefTo<T>(T.Name)иT.Definition. - 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 после миграции блока:
- Открыта
MIGRATION_MAP.md. - Для каждого нового cs-файла в блоке — есть строка в соответствующей таблице.
- Если блок целиком новый (новый эпик / новая категория) — есть отдельный
## …-заголовок над таблицей.
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 содержит:
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>.
<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))— для отдельного члена.
Чеклист на одну сущность
- Создать
50_Domain/<Module>/<Entity>.cs. - Скопировать класс сущности из источника, проставить
[DomainEntity]и[Relation]. Per-property XML-doc — без изменений. - Из верхнеуровневого
<summary>оставить только первое предложение. - Над классом добавить
DOC_<Entity>с_refName,Name,Description(verbatim),Entity => typeof(<Entity>), chunks по смысловым блокам, финальныйBodyсоSdaidCode.OfClass<X>(). - Если в модуле ещё нет
BC_<Module>.cs— создать (без выдуманной прозы). - Обновить
MIGRATION_MAP.md. - Сборка + регенерация + визуальная проверка страницы.
Подводные камни
init-сеттеры в netstandard2.0 — нужен polyfillIsExternalInit(уже добавлен в_Polyfills.cs); там же stubDateOnly.- 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:
- Прочитать нотацию:
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. Это контракты, ничего без них не делается. - Прочитать этот HowTo целиком.
- Прочитать текущее состояние спеки:
sdaid/Rugrid.Sdaid.Epas/. Если что-то уже мигрировано — пересмотреть на соответствие правилам этого HowTo. - Если ловится дрейф (перефразированный текст, фабрикованные термины, row-specific хелперы) — поправить до новой работы. Иначе старые ошибки расползутся в новые файлы.
- Прочитать источник (
rugrid-devdocs/service-enter/,rugrid-ntr/docs/) — что ещё не мигрировано, сверяясь сMIGRATION_MAP.md. - Спросить пользователя: с какого источника продолжаем — vision, конкретный эпик, глоссарий, ADR?
- Только после этого начинать миграцию следующего блока по фазам из секции «Фазы миграции».
Признаки что чеклист пропущен
- Появляется prose в
Body, которое не цитируется из источника → перефразирование вернулось. - Создаются классы для артефактов, которых нет в источнике → фабрикация.
- Появляются строковые литералы в
RefToдля типов, у которых есть статикаCode/Name→ пропустил прямое обращение. - Таблица оформлена через row-specific helper → пропустил value-cell pattern.
Если поймали — остановиться, перечитать секцию «Антипаттерны», переделать.