diff --git a/.opencode/skills/feature-spec/SKILL.md b/.opencode/skills/feature-spec/SKILL.md index b80fd5d..7fb4fa5 100644 --- a/.opencode/skills/feature-spec/SKILL.md +++ b/.opencode/skills/feature-spec/SKILL.md @@ -1,6 +1,6 @@ --- name: feature-spec -description: Lightweight SDD-style Q&A skill for feature planning. Guides the agent through Spec-Driven Development questions before implementation. Produces a structured plan with 7 SDD sections (Контекст, Задача, Контракты, Инварианты, Граничные случаи, Вне scope, Критерии приемки). Also when user says "спека фичи", "feature spec", "план фичи", "обсудим фичу", "spec feature", "спецификация фичи". +description: Lightweight SDD-style Q&A skill for feature planning. Guides the agent through Spec-Driven Development questions before implementation. Runs an explore subagent to find related/linked components (oracle scripts, validators, agents, prompts) before Q&A, then produces a structured plan with 8 SDD sections (Контекст, Задача, Контракты, Инварианты, Граничные случаи, Влияние на связанные компоненты, Вне scope, Критерии приемки). Also when user says "спека фичи", "feature spec", "план фичи", "обсудим фичу", "spec feature", "спецификация фичи". --- # Feature Spec @@ -21,6 +21,21 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag Юзер описывает фичу. Агент читает SDD-шаблон (ниже) и определяет, каких данных не хватает. +### 1.5. Поиск связанных компонентов + +ДО Q&A — автоматический search по репо, чтобы найти связанные компоненты и дать вводные для 8-й SDD-секции. + +- Запустить `explore` subagent с `rg` по репо. +- Найти: кто ссылается на изменяемый файл/функцию/формат/литерал/frontmatter key. +- Категории для поиска: + - oracle-скрипты (`pipeline-status`, `spec-status`, `project-status`) + - валидаторы (`create-issue`, `create-readme`) + - агенты (`memory-syncer`, `reviewer`) + - промпты (skills) +- Для каждого найденного компонента — отметить: как изменение повлияет, нужен ли paired update. +- Результат — вводные для 8-й секции SDD (`## Влияние на связанные компоненты`). +- Если связанных компонентов нет — явно отметить (explore search не нашёл). + ### 2. Q&A Агент задаёт вопросы списком — НЕ гадает. Пример: @@ -30,10 +45,11 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag 1. Контракты: какой формат запроса/ответа? Какие коды ошибок? 2. Инварианты: какие лимиты? Какой TTL? Какая модель/библиотека? 3. Граничные случаи: что если внешний сервис недоступен? Что если данных нет? -4. Вне scope: что точно НЕ делаем в этой итерации? +4. Влияние на связанные компоненты: какие детерминированные связи (oracle-скрипты, валидаторы, парсеры, промпты-агенты) зависят от этого изменения? Что может сломаться если поменять X? Нужны ли paired updates в других файлах? (explore subagent уже нашёл candidates на шаге 1.5 — юзер подтверждает/дополняет) +5. Вне scope: что точно НЕ делаем в этой итерации? ``` -Юзер отвечает. Если ответ неполный — агент уточняет. Q&A продолжается пока все 7 секций не заполнены. +Юзер отвечает. Если ответ неполный — агент уточняет. Q&A продолжается пока все 8 секций не заполнены. ### 3. План @@ -56,6 +72,9 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag ## Граничные случаи [Что при ошибках: невалидный вход, отказ сервиса, превышение лимита] +## Влияние на связанные компоненты +[Файлы/оракулы/агенты/промпты/валидаторы, которые зависят от изменения; нужен ли paired update. Если нет — явно «нет связанных компонентов»] + ## Вне scope [Что НЕ делаем] @@ -68,7 +87,7 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag После готовности плана: - Агент: "План готов. Скажи 'создай issue' чтобы создать issue, потом запусти /run-pipeline." -- Юзер: "создай issue" → загружается `issue` скилл → `create-issue` tool (7 секций) → issue создан +- Юзер: "создай issue" → загружается `issue` скилл → `create-issue` tool (8 секций) → issue создан - Юзер: `/run-pipeline` → реализация ## Правила @@ -83,9 +102,9 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag - **Не создавай файлы** — план живёт в чате, потом в issue - **1 issue = 1 PR** — если фича большая, предложи разбить на подзадачи -## SDD-шаблон (7 секций) +## SDD-шаблон (8 секций) -Совпадает с `create-issue` validation (PR #169): +Совпадает с `create-issue` validation (PR #169, расширено в #249): | # | Секция | Что содержит | |---|--------|-------------| @@ -94,5 +113,29 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag | 3 | `## Контракты` | Ожидаемое поведение: API, форматы, коды ошибок | | 4 | `## Инварианты` | Правила без исключений: лимиты, ограничения, технологии | | 5 | `## Граничные случаи` | Что при ошибках: edge cases, отказы сервисов | -| 6 | `## Вне scope` | Что НЕ делаем в этой итерации | -| 7 | `## Критерии приемки` | Проверяемые сценарии: "X → видит Y" | +| 6 | `## Влияние на связанные компоненты` | Файлы/оракулы/агенты/промпты/валидаторы, зависящие от изменения; paired updates; «нет связанных компонентов» для тривиальных фич | +| 7 | `## Вне scope` | Что НЕ делаем в этой итерации | +| 8 | `## Критерии приемки` | Проверяемые сценарии: "X → видит Y" | + +## Пример: кейс #238 (memory-syncer ↔ pipeline-status) + +Реальный кейс, который мотивировал 8-ю секцию. PR #239 изменил `memory-syncer.md` (файл-ротация: пишет в `{repo}-002.md` при заморозке). `pipeline-status.py::check_memory()` читал только `{repo}.md` → не нашёл receipt → pipeline завис. Связь writer↔reader детерминированная, но feature-spec её не увидел. + +Шаг 1.5 (explore subagent) нашёл бы: +- `pipeline-status.py::check_memory()` / `get_memory_file_path()` — читает `{repo}.md` для поиска `PR#N` receipt. Если memory-syncer пишет в `{repo}-002.md` → оракул не найдёт receipt → MEMORY фаза зависает. Нужен paired update: `get_memory_file_path()` должен сканировать `{repo}*.md` glob. +- `memory` skill — ссылается на формат memory-файлов, обновить примеры ротации. + +8-я секция SDD для такой фичи: +``` +## Влияние на связанные компоненты + +- `pipeline-status.py:check_memory()` / `get_memory_file_path()` — читает `{repo}.md` для поиска `PR#N` receipt. Если memory-syncer пишет в `{repo}-002.md` → оракул не найдёт receipt → MEMORY фаза зависает. Нужен paired update: `get_memory_file_path()` должен сканировать `{repo}*.md` glob. +- `memory` skill — ссылается на формат memory-файлов, обновить примеры ротации. +``` + +Для тривиальной фичи (cosmetic README update): +``` +## Влияние на связанные компоненты + +Нет связанных компонентов (cosmetic README update). +``` diff --git a/.opencode/tools/create-issue.ts b/.opencode/tools/create-issue.ts index 5c20c1c..e08fef1 100644 --- a/.opencode/tools/create-issue.ts +++ b/.opencode/tools/create-issue.ts @@ -11,15 +11,16 @@ const RULES = `Rules: - Body must contain '## Контракты' heading (ожидаемое поведение / API) - Body must contain '## Инварианты' heading (правила без исключений) - Body must contain '## Граничные случаи' heading (что при ошибках) +- Body must contain '## Влияние на связанные компоненты' heading (связанные файлы/оракулы/агенты/промпты/валидаторы; paired updates; «нет связанных компонентов» для тривиальных фич) - Body must contain '## Вне scope' heading (что НЕ делаем) - Body must contain '## Критерии приемки' heading (как проверяем) - Body must contain Cyrillic (Russian language required)` export default tool({ - description: "Create a GitHub issue with title/body validation. Validates: title format type(scope): description (<=80), English title, body headings (## Контекст, ## Задача, ## Контракты, ## Инварианты, ## Граничные случаи, ## Вне scope, ## Критерии приемки), body in Russian. Optional labels. Returns issue URL on success.", + description: "Create a GitHub issue with title/body validation. Validates: title format type(scope): description (<=80), English title, body headings (## Контекст, ## Задача, ## Контракты, ## Инварианты, ## Граничные случаи, ## Влияние на связанные компоненты, ## Вне scope, ## Критерии приемки), body in Russian. Optional labels. Returns issue URL on success.", args: { title: tool.schema.string().describe("Issue title (conventional format: type(scope): description, <=80 chars)"), - body: tool.schema.string().describe("Issue body in Russian with ## Контекст, ## Задача, ## Контракты, ## Инварианты, ## Граничные случаи, ## Вне scope, ## Критерии приемки headings"), + body: tool.schema.string().describe("Issue body in Russian with ## Контекст, ## Задача, ## Контракты, ## Инварианты, ## Граничные случаи, ## Влияние на связанные компоненты, ## Вне scope, ## Критерии приемки headings"), labels: tool.schema.array(tool.schema.string()).optional().describe("Labels to assign (e.g. ['bug', 'enhancement'])"), repo: tool.schema.string().optional().describe("Optional repo (owner/name). If omitted, gh auto-detects from context.worktree."), }, @@ -51,6 +52,9 @@ export default tool({ if (!body.includes("## Граничные случаи")) { return `❌ Issue body must contain '## Граничные случаи' heading\n\n${RULES}` } + if (!body.includes("## Влияние на связанные компоненты")) { + return `❌ Issue body must contain '## Влияние на связанные компоненты' heading\n\n${RULES}` + } if (!body.includes("## Вне scope")) { return `❌ Issue body must contain '## Вне scope' heading\n\n${RULES}` } diff --git a/tests/test_create_issue_tool.py b/tests/test_create_issue_tool.py index b623a44..4a1c7e6 100644 --- a/tests/test_create_issue_tool.py +++ b/tests/test_create_issue_tool.py @@ -12,10 +12,15 @@ Modes used: labels args. - ``exec_stub_json`` — call execute with a stubbed spawnSync to verify: (a) success path: valid title + body → "Issue created: ", - (b) validation errors: title >80, missing sections, Latin-only body, + (b) validation errors: title >80, missing sections (8 SDD headings), + Latin-only body, (c) labels: passed through to gh issue create. create-issue.ts makes 1 spawnSync call (gh issue create) on the success path. + +SDD sections validated (in order, 8 since #249): Контекст, Задача, Контракты, +Инварианты, Граничные случаи, Влияние на связанные компоненты, Вне scope, +Критерии приемки. """ import json @@ -34,9 +39,10 @@ VALID_TITLE = "feat(tools): add create-issue tool validation" VALID_BODY = ( "## Контекст\nНужен tool\n\n" "## Задача\nСоздать tool\n\n" - "## Контракты\nTool валидирует 7 секций\n\n" + "## Контракты\nTool валидирует 8 секций\n\n" "## Инварианты\nВсе секции обязательны\n\n" "## Граничные случаи\nНет секций → error\n\n" + "## Влияние на связанные компоненты\nНет связанных компонентов (валидатор изолирован)\n\n" "## Вне scope\nИзменение gh CLI\n\n" "## Критерии приемки\nTool работает" ) @@ -117,6 +123,7 @@ def test_missing_kontekst(): "## Контракты\nКонтракты\n\n" "## Инварианты\nПравила\n\n" "## Граничные случаи\nОшибки\n\n" + "## Влияние на связанные компоненты\nНет связанных компонентов\n\n" "## Вне scope\nНе делаем\n\n" "## Критерии приемки\nГотово" ) @@ -135,6 +142,7 @@ def test_missing_zadacha(): "## Контракты\nКонтракты\n\n" "## Инварианты\nПравила\n\n" "## Граничные случаи\nОшибки\n\n" + "## Влияние на связанные компоненты\nНет связанных компонентов\n\n" "## Вне scope\nНе делаем\n\n" "## Критерии приемки\nГотово" ) @@ -154,6 +162,7 @@ def test_missing_kriterii(): "## Контракты\nКонтракты\n\n" "## Инварианты\nПравила\n\n" "## Граничные случаи\nОшибки\n\n" + "## Влияние на связанные компоненты\nНет связанных компонентов\n\n" "## Вне scope\nНе делаем" ) out = _run_exec( @@ -171,6 +180,7 @@ def test_sdd_missing_contracts(): "## Задача\nСделать\n\n" "## Инварианты\nПравила\n\n" "## Граничные случаи\nОшибки\n\n" + "## Влияние на связанные компоненты\nНет связанных компонентов\n\n" "## Вне scope\nНе делаем\n\n" "## Критерии приемки\nГотово" ) @@ -187,6 +197,7 @@ def test_sdd_missing_invariants(): "## Задача\nСделать\n\n" "## Контракты\nКонтракты\n\n" "## Граничные случаи\nОшибки\n\n" + "## Влияние на связанные компоненты\nНет связанных компонентов\n\n" "## Вне scope\nНе делаем\n\n" "## Критерии приемки\nГотово" ) @@ -203,6 +214,7 @@ def test_sdd_missing_edge_cases(): "## Задача\nСделать\n\n" "## Контракты\nКонтракты\n\n" "## Инварианты\nПравила\n\n" + "## Влияние на связанные компоненты\nНет связанных компонентов\n\n" "## Вне scope\nНе делаем\n\n" "## Критерии приемки\nГотово" ) @@ -212,6 +224,50 @@ def test_sdd_missing_edge_cases(): assert "error" in result.lower() or "❌" in result +def test_sdd_missing_impact(): + """execute() with body missing '## Влияние на связанные компоненты' → error. + + 8th SDD section (added in #249). A body with the original 7 sections but + without the new impact-analysis heading must be rejected. + """ + body = ( + "## Контекст\nКонтекст\n\n" + "## Задача\nСделать\n\n" + "## Контракты\nКонтракты\n\n" + "## Инварианты\nПравила\n\n" + "## Граничные случаи\nОшибки\n\n" + "## Вне scope\nНе делаем\n\n" + "## Критерии приемки\nГотово" + ) + out = _run_exec({"title": VALID_TITLE, "body": body}, [ISSUE_OK_RESPONSE]) + result = out["result"] + assert "## Влияние на связанные компоненты" in result, ( + f"expected 8th-section heading error, got: {result!r}" + ) + assert "error" in result.lower() or "❌" in result + + +def test_sdd_impact_trivial_ok(): + """execute() with 8th section 'нет связанных компонентов' → success. + + Trivial features (cosmetic README updates, isolated validator changes) + use 'нет связанных компонентов' as a valid 8th-section answer. + """ + body = ( + "## Контекст\nНужен tool\n\n" + "## Задача\nСоздать tool\n\n" + "## Контракты\nTool валидирует 8 секций\n\n" + "## Инварианты\nВсе секции обязательны\n\n" + "## Граничные случаи\nНет секций → error\n\n" + "## Влияние на связанные компоненты\nНет связанных компонентов (cosmetic update)\n\n" + "## Вне scope\nИзменение gh CLI\n\n" + "## Критерии приемки\nTool работает" + ) + out = _run_exec({"title": VALID_TITLE, "body": body}, [ISSUE_OK_RESPONSE]) + result = out["result"] + assert result == f"Issue created: {ISSUE_URL}", f"expected success, got: {result!r}" + + def test_sdd_missing_out_of_scope(): """execute() with body missing '## Вне scope' → error mentioning heading.""" body = ( @@ -220,6 +276,7 @@ def test_sdd_missing_out_of_scope(): "## Контракты\nКонтракты\n\n" "## Инварианты\nПравила\n\n" "## Граничные случаи\nОшибки\n\n" + "## Влияние на связанные компоненты\nНет связанных компонентов\n\n" "## Критерии приемки\nГотово" ) out = _run_exec({"title": VALID_TITLE, "body": body}, [ISSUE_OK_RESPONSE]) diff --git a/tests/test_create_issue_tool.ts b/tests/test_create_issue_tool.ts index 6007a52..5d29187 100644 --- a/tests/test_create_issue_tool.ts +++ b/tests/test_create_issue_tool.ts @@ -18,6 +18,12 @@ * - test_missing_kontekst — body missing ## Контекст → error * - test_missing_zadacha — body missing ## Задача → error * - test_missing_kriterii — body missing ## Критерии приемки → error + * - test_sdd_missing_contracts — body missing ## Контракты → error + * - test_sdd_missing_invariants — body missing ## Инварианты → error + * - test_sdd_missing_edge_cases — body missing ## Граничные случаи → error + * - test_sdd_missing_impact — body missing ## Влияние на связанные компоненты → error + * - test_sdd_impact_trivial_ok — 8th section 'нет связанных компонентов' → success + * - test_sdd_missing_out_of_scope — body missing ## Вне scope → error * - test_latin_only_body — body without Cyrillic → error */ @@ -36,7 +42,7 @@ function ctx() { } } -const VALID_BODY = "## Контекст\nНужен tool\n\n## Задача\nСоздать tool\n\n## Контракты\nTool валидирует 7 секций\n\n## Инварианты\nВсе секции обязательны\n\n## Граничные случаи\nНет секций → error\n\n## Вне scope\nИзменение gh CLI\n\n## Критерии приемки\nTool работает" +const VALID_BODY = "## Контекст\nНужен tool\n\n## Задача\nСоздать tool\n\n## Контракты\nTool валидирует 8 секций\n\n## Инварианты\nВсе секции обязательны\n\n## Граничные случаи\nНет секций → error\n\n## Влияние на связанные компоненты\nНет связанных компонентов (валидатор изолирован)\n\n## Вне scope\nИзменение gh CLI\n\n## Критерии приемки\nTool работает" describe("create-issue tool", () => { test("test_valid_issue — valid title + body succeeds", async () => { @@ -71,7 +77,7 @@ describe("create-issue tool", () => { const mod = await import(TOOL_SRC + "?t=" + Date.now()) const result = await mod.default.execute({ title: "feat(tools): valid title", - body: "## Задача\nСделать\n\n## Контракты\nКонтракты\n\n## Инварианты\nПравила\n\n## Граничные случаи\nОшибки\n\n## Вне scope\nНе делаем\n\n## Критерии приемки\nГотово", + body: "## Задача\nСделать\n\n## Контракты\nКонтракты\n\n## Инварианты\nПравила\n\n## Граничные случаи\nОшибки\n\n## Влияние на связанные компоненты\nНет связанных компонентов\n\n## Вне scope\nНе делаем\n\n## Критерии приемки\nГотово", }, ctx()) expect(result).toContain("## Контекст") }) @@ -83,7 +89,7 @@ describe("create-issue tool", () => { const mod = await import(TOOL_SRC + "?t=" + Date.now()) const result = await mod.default.execute({ title: "feat(tools): valid title", - body: "## Контекст\nКонтекст\n\n## Контракты\nКонтракты\n\n## Инварианты\nПравила\n\n## Граничные случаи\nОшибки\n\n## Вне scope\nНе делаем\n\n## Критерии приемки\nГотово", + body: "## Контекст\nКонтекст\n\n## Контракты\nКонтракты\n\n## Инварианты\nПравила\n\n## Граничные случаи\nОшибки\n\n## Влияние на связанные компоненты\nНет связанных компонентов\n\n## Вне scope\nНе делаем\n\n## Критерии приемки\nГотово", }, ctx()) expect(result).toContain("## Задача") }) @@ -95,7 +101,7 @@ describe("create-issue tool", () => { const mod = await import(TOOL_SRC + "?t=" + Date.now()) const result = await mod.default.execute({ title: "feat(tools): valid title", - body: "## Контекст\nКонтекст\n\n## Задача\nСделать\n\n## Контракты\nКонтракты\n\n## Инварианты\nПравила\n\n## Граничные случаи\nОшибки\n\n## Вне scope\nНе делаем", + body: "## Контекст\nКонтекст\n\n## Задача\nСделать\n\n## Контракты\nКонтракты\n\n## Инварианты\nПравила\n\n## Граничные случаи\nОшибки\n\n## Влияние на связанные компоненты\nНет связанных компонентов\n\n## Вне scope\nНе делаем", }, ctx()) expect(result).toContain("## Критерии приемки") }) @@ -107,7 +113,7 @@ describe("create-issue tool", () => { const mod = await import(TOOL_SRC + "?t=" + Date.now()) const result = await mod.default.execute({ title: "feat(tools): valid title", - body: "## Контекст\nКонтекст\n\n## Задача\nСделать\n\n## Инварианты\nПравила\n\n## Граничные случаи\nОшибки\n\n## Вне scope\nНе делаем\n\n## Критерии приемки\nГотово", + body: "## Контекст\nКонтекст\n\n## Задача\nСделать\n\n## Инварианты\nПравила\n\n## Граничные случаи\nОшибки\n\n## Влияние на связанные компоненты\nНет связанных компонентов\n\n## Вне scope\nНе делаем\n\n## Критерии приемки\nГотово", }, ctx()) expect(result).toContain("## Контракты") }) @@ -119,7 +125,7 @@ describe("create-issue tool", () => { const mod = await import(TOOL_SRC + "?t=" + Date.now()) const result = await mod.default.execute({ title: "feat(tools): valid title", - body: "## Контекст\nКонтекст\n\n## Задача\nСделать\n\n## Контракты\nКонтракты\n\n## Граничные случаи\nОшибки\n\n## Вне scope\nНе делаем\n\n## Критерии приемки\nГотово", + body: "## Контекст\nКонтекст\n\n## Задача\nСделать\n\n## Контракты\nКонтракты\n\n## Граничные случаи\nОшибки\n\n## Влияние на связанные компоненты\nНет связанных компонентов\n\n## Вне scope\nНе делаем\n\n## Критерии приемки\nГотово", }, ctx()) expect(result).toContain("## Инварианты") }) @@ -131,11 +137,35 @@ describe("create-issue tool", () => { const mod = await import(TOOL_SRC + "?t=" + Date.now()) const result = await mod.default.execute({ title: "feat(tools): valid title", - body: "## Контекст\nКонтекст\n\n## Задача\nСделать\n\n## Контракты\nКонтракты\n\n## Инварианты\nПравила\n\n## Вне scope\nНе делаем\n\n## Критерии приемки\nГотово", + body: "## Контекст\nКонтекст\n\n## Задача\nСделать\n\n## Контракты\nКонтракты\n\n## Инварианты\nПравила\n\n## Влияние на связанные компоненты\nНет связанных компонентов\n\n## Вне scope\nНе делаем\n\n## Критерии приемки\nГотово", }, ctx()) expect(result).toContain("## Граничные случаи") }) + test("test_sdd_missing_impact — body missing ## Влияние на связанные компоненты → error", async () => { + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: "url\n", stderr: "" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({ + title: "feat(tools): valid title", + body: "## Контекст\nКонтекст\n\n## Задача\nСделать\n\n## Контракты\nКонтракты\n\n## Инварианты\nПравила\n\n## Граничные случаи\nОшибки\n\n## Вне scope\nНе делаем\n\n## Критерии приемки\nГотово", + }, ctx()) + expect(result).toContain("## Влияние на связанные компоненты") + }) + + test("test_sdd_impact_trivial_ok — 8th section 'нет связанных компонентов' → success", async () => { + mock.module("child_process", () => ({ + spawnSync: () => ({ status: 0, stdout: "https://github.com/x/y/issues/1\n", stderr: "" }), + })) + const mod = await import(TOOL_SRC + "?t=" + Date.now()) + const result = await mod.default.execute({ + title: "feat(tools): valid title", + body: VALID_BODY, + }, ctx()) + expect(result).toBe("Issue created: https://github.com/x/y/issues/1") + }) + test("test_sdd_missing_out_of_scope — body missing ## Вне scope → error", async () => { mock.module("child_process", () => ({ spawnSync: () => ({ status: 0, stdout: "url\n", stderr: "" }), @@ -143,7 +173,7 @@ describe("create-issue tool", () => { const mod = await import(TOOL_SRC + "?t=" + Date.now()) const result = await mod.default.execute({ title: "feat(tools): valid title", - body: "## Контекст\nКонтекст\n\n## Задача\nСделать\n\n## Контракты\nКонтракты\n\n## Инварианты\nПравила\n\n## Граничные случаи\nОшибки\n\n## Критерии приемки\nГотово", + body: "## Контекст\nКонтекст\n\n## Задача\nСделать\n\n## Контракты\nКонтракты\n\n## Инварианты\nПравила\n\n## Граничные случаи\nОшибки\n\n## Влияние на связанные компоненты\nНет связанных компонентов\n\n## Критерии приемки\nГотово", }, ctx()) expect(result).toContain("## Вне scope") })