From 7d3b05a39f58c7cd49957708f51f00018cc81bd3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 18:35:57 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D0=BD:=20=D0=BF=D0=B0=D0=BA?= =?UTF-8?q?=D0=B5=D1=82=20D=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=20wiki?= =?UTF-8?q?=20(#135)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-27-wiki-package-d.md | 385 ++++++++++++++++++ 1 file changed, 385 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-27-wiki-package-d.md diff --git a/docs/superpowers/plans/2026-10-27-wiki-package-d.md b/docs/superpowers/plans/2026-10-27-wiki-package-d.md new file mode 100644 index 0000000..a7f6d94 --- /dev/null +++ b/docs/superpowers/plans/2026-10-27-wiki-package-d.md @@ -0,0 +1,385 @@ +# Пакет D модуля wiki — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** закрыть маркеры #94 T6.1–T6.5, T8.2, T8.3, T9.3 и показать скачанные картинки в тексте страницы «Вики» (sdlc-hub#135). + +**Architecture:** точечные правки модуля `wiki`: перевод HTML (`convert.py`), постановка `wiki_sync` (`commands.py`), +вложения (`attachments.py`, `api.py`), MCP (`mcp.py`); во фронте — режим «показать» у скачивания вложения и хранилище +картинок страницы (`download.ts`, `PageView.tsx`). Схема базы и контракт с агентом не меняются. + +**Tech Stack:** Python 3.13 (uv), pytest, BeautifulSoup + markdownify; React 19 + TypeScript, Vitest, Testing Library. + +**Spec:** `openspec/changes/wiki-package-d/` (proposal.md, design.md — решения 1–10, specs/wiki, specs/mcp, specs/web-app). + +## Global Constraints + +- Только хаб `sdlc-hub`; eva-agent и jira-agent не меняются, `SCHEMA_VERSION` не меняется. +- Граница ядра: в `src/sdlc_hub/core/` правок нет (проверяют `tests/test_registry.py`). +- Тексты ошибок и комментарии — по-русски, в стиле соседнего кода. +- Расширения картинок: `.png .jpg .jpeg .gif .webp .bmp` без учёта регистра; SVG — не картинка. +- Пробел → `%20`, `(` → `%28`, `)` → `%29` — только во внешних адресах ссылок и картинок. +- Зелёным считается только весь набор: в конце `make check` (pytest + lint и тесты фронта). +- Соединения SQLite в тестах — через фикстуры `open_db`/`conn`, иначе явный `close()`. + +## Review Focus + +1. Ссылка на страницу, внутри которой и картинка-вложение, и текст (`Схема`) — вложение перед ссылкой, текст ссылки «Схема». Тест в Task 1. +2. Внешняя ссылка с уже закодированным адресом (`%20` в `href`) не кодируется повторно (`%2520`). Тест в Task 1. +3. Повтор упавшей синхронизации, когда идущих синхронизаций нет, проходит как раньше. Тест в Task 2. +4. Вложение `ready` с файлом на месте — `fetch` отвечает 200 `ready` без нового пакета (T8.2 не ломает обычный путь). Тест в Task 3. +5. Смена страницы, пока картинка грузится: поздний ответ `content` не кладёт картинку на новую страницу, objectURL старой отозваны. Тест в Task 5. + +--- + +### Task 1: Перевод HTML — T6.1–T6.5 + +**Files:** +- Modify: `src/sdlc_hub/modules/wiki/convert.py` (`_Converter.__init__`, `convert_img`, `convert_a`, `_label`, новые `_href`, `_split_marks`) +- Test: `tests/test_wiki_convert.py` + +**Interfaces:** +- Produces: `convert.to_markdown(html, attachments, links) -> (str, list[dict])` — сигнатура прежняя. + +- [ ] **Step 1: Написать падающие тесты** (константы `FILE_REF`, `THUMB_REF`, `IMG_REF`, `FILE_URL` уже в файле; `IMG_URL = "https://eva.example.test" + IMG_REF`, `FILE = {"ref": FILE_REF, "url": FILE_URL, "name": "model.xlsx"}`, `THUMB = {"ref": THUMB_REF, "url": FILE_URL, "name": "model.xlsx"}`, `IMG = {"ref": IMG_REF, "url": IMG_URL, "name": "scheme.png"}`, `LINK = {"ref": "project/Document/DOC-1", "code": "DOC-1"}`) + +```python +def test_thumbnail_in_wrapper_keeps_link(): # T6.1 + md, atts = convert.to_markdown(f'

текст

', [THUMB, FILE], []) + assert md == "[📎 model.xlsx](attachment:@0) текст\n" and len(atts) == 1 + +def test_other_attachment_inside_attachment_link(): # T6.1, design 1 + md, atts = convert.to_markdown(f'

', [FILE, IMG], []) + assert md == "[📎 scheme.png](attachment:@0)[📎 model.xlsx](attachment:@1)\n" + assert [a["name"] for a in atts] == ["scheme.png", "model.xlsx"] + +def test_attachment_inside_page_link(): # T6.2 + md, _ = convert.to_markdown(f'

', [IMG], [LINK]) + assert md == "[📎 scheme.png](attachment:@0) [DOC-1](page:DOC-1)\n" + +def test_attachment_and_text_inside_page_link(): # Review Focus 1 + md, _ = convert.to_markdown(f'

Схема

', [IMG], [LINK]) + assert md == "[📎 scheme.png](attachment:@0) [Схема](page:DOC-1)\n" + +def test_attachment_inside_external_link(): # design 2 + md, _ = convert.to_markdown(f'

', [IMG], []) + assert md == "[📎 scheme.png](attachment:@0) [https://ex.com/x](https://ex.com/x)\n" + +def test_duplicate_ref_first_wins(): # T6.3 + md, atts = convert.to_markdown(f'

', [dict(IMG, name="first"), dict(IMG, url="u2", name="second")], []) + assert atts == [{"url": IMG_URL, "name": "first"}] + md, _ = convert.to_markdown('

т

', [], [LINK, dict(LINK, code="DOC-9")]) + assert "(page:DOC-1)" in md + +def test_external_href_spaces_and_parens_encoded(): # T6.4 + md, _ = convert.to_markdown('

док

', [], []) + assert md == "[док](http://ex.com/a%20b%28c%29.html)\n" + +def test_external_href_already_encoded_untouched(): # Review Focus 2 + md, _ = convert.to_markdown('

док

', [], []) + assert "(http://ex.com/a%20b.html)" in md and "%2520" not in md + +def test_trailing_backslash_in_page_link_text(): # T6.5 + md, _ = convert.to_markdown('

путь C:\\

', [], [LINK]) + assert md == "[путь C:\\\\](page:DOC-1)\n" # в Markdown: [путь C:\\](page:DOC-1) + +def test_trailing_backslash_in_external_link_text(): # T6.5 + md, _ = convert.to_markdown('

путь C:\\

', [], []) + assert md == "[путь C:\\\\](http://ex.com/)\n" +``` + +Если markdownify уже экранирует `\` в тексте, удвоение в `_label` не нужно — решают тесты. + +- [ ] **Step 2: Запустить — тесты падают** + +Run: `uv run pytest tests/test_wiki_convert.py -q` +Expected: новые тесты FAIL, прежние PASS. + +- [ ] **Step 3: Реализовать в `convert.py`** + - `_by_ref`, `_links` — первая запись по `ref` (`setdefault` в цикле). + - `convert_img`: предок — `el.find_parent("a")`; если его `href` в `_by_ref` и (`src` не в `_by_ref` или `url` записи `src` == `url` записи `href`) — `""`. + - `_split_marks(text: str) -> tuple[str, str]` — (все фрагменты `[📎 …<маркер>` подряд, остаток текста без них); фрагмент вложения — `"[📎 " + имя + _MARK_OPEN + n + _MARK_CLOSE`, искать регуляркой до `_MARK_CLOSE`. + - `convert_a`: для вложения — `marks + self._attachment(entry)` (маркеры из `text` сохраняются перед своей ссылкой); для ссылки на страницу — `marks + (" " if marks else "") + "[%s](page:%s)" % (_label(rest.strip()) or _name(code), code)`; для внешней — то же разбиение, ссылка через `super().convert_a` с `el["href"] = _href(href)` и текстом-остатком (пустой — адрес). + - `convert_img` для внешней картинки — `el["src"] = _href(src)` перед `super().convert_img`. + - `_href(url: str) -> str`: `url.replace(" ", "%20").replace("(", "%28").replace(")", "%29")`. + - `_label`: нечётная серия `\` в конце строки удваивается до чётной. + +- [ ] **Step 4: Запустить — всё зелёное** + +Run: `uv run pytest tests/test_wiki_convert.py tests/test_wiki_sync.py -q` +Expected: PASS. + +- [ ] **Step 5: Коммит** + +```bash +git add src/sdlc_hub/modules/wiki/convert.py tests/test_wiki_convert.py +git commit -m "Вики: перевод HTML — миниатюра в обёртке, вложение в ссылке, кодирование адресов (#135, T6.1–T6.5)" +``` + +### Task 2: Одна синхронизация на корень — T8.3 + +**Files:** +- Modify: `src/sdlc_hub/modules/wiki/commands.py` (`check_batch`, `enqueue_sync`, новая `_active_same_root`) +- Test: `tests/test_wiki_commands.py`, `tests/test_wiki_projects.py` + +**Interfaces:** +- Produces: `commands._active_same_root(conn, label: str, root: str) -> int | None` — id пакета с `wiki_sync` проекта в `queued`/`claimed` и `params.root == root` (по `json_extract(c.params, '$.root')`), самый новый. +- Produces: `commands.enqueue_sync(conn, label, root, author) -> dict` — `{id, estimate_seconds, count}`; при идущей синхронизации того же корня — её пакет (`estimate_seconds` из `batch`, `count` — число команд пакета). + +- [ ] **Step 1: Написать падающие тесты** + +```python +# tests/test_wiki_commands.py +SYNC = [{"type": "wiki_sync", "params": {"label": "AWPCA", "root": "DOC-1"}}] + +def test_check_batch_refuses_second_sync_same_root(conn): + b = queue.create_batch(conn, "t", "u", SYNC, project="AWPCA") + with pytest.raises(CommandError, match=f"#{b['id']}"): + queue.create_batch(conn, "t", "u", SYNC, project="AWPCA") + +def test_check_batch_other_root_allowed(conn): + queue.create_batch(conn, "t", "u", SYNC, project="AWPCA") + other = [{"type": "wiki_sync", "params": {"label": "AWPCA", "root": "DOC-2"}}] + assert queue.create_batch(conn, "t", "u", other, project="AWPCA")["count"] == 1 + +def test_retry_failed_sync_without_active_passes(conn): # Review Focus 3 + b = queue.create_batch(conn, "t", "u", SYNC, project="AWPCA") + conn.execute("UPDATE command SET status = 'failed' WHERE batch_id = ?", (b["id"],)) + assert queue.create_batch(conn, "t", "u", SYNC, project="AWPCA")["count"] == 1 + +def test_enqueue_sync_returns_active_same_root(conn): + _root(conn) + first = commands.enqueue_sync(conn, "AWPCA", "DOC-1", "u") + again = commands.enqueue_sync(conn, "AWPCA", "DOC-1", "u") + assert again["id"] == first["id"] and again["count"] == 1 and _count(conn) == 1 +``` + +```python +# tests/test_wiki_projects.py +def test_root_back_while_old_claimed(hub_server): # A → B → A + c = hub_server["client"] + c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-12"}) + conn = hub_server["hub"].conn() + try: + conn.execute("UPDATE command SET status = 'claimed' WHERE type = 'wiki_sync'") + finally: + conn.close() + c.patch("/projects/AWPCA", {"wiki_root": "DOC-21"}) + c.patch("/projects/AWPCA", {"wiki_root": "DOC-12"}) # не 422 + assert [(s, p["root"]) for _, s, p in _syncs(hub_server["hub"])] == [("claimed", "DOC-12"), ("cancelled", "DOC-21")] + +def test_retry_while_sync_running_refused(hub_server): # сценарий spec «Повтор упавшей синхронизации…» + c = hub_server["client"] + c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-1"}) + conn = hub_server["hub"].conn() + try: + failed = conn.execute("SELECT id FROM command WHERE type = 'wiki_sync'").fetchone()["id"] + conn.execute("UPDATE command SET status = 'failed' WHERE id = ?", (failed,)) + finally: + conn.close() + running = c.post("/wiki/sync", {"project": "AWPCA"})["batch_id"] + with pytest.raises(HubError) as e: + c.post(f"/commands/{failed}/retry", {}) + assert e.value.status == 422 and f"#{running}" in str(e.value) +``` + +Статус отменённой команды в `_syncs` — каким его пишет `queue.cancel_batch` (исполнитель сверяет по +`test_patch_root_cancels_queued` и подставляет фактический). Атрибуты `HubError` (`status`, текст) — как в соседних тестах файла. + +- [ ] **Step 2: Запустить — тесты падают** + +Run: `uv run pytest tests/test_wiki_commands.py tests/test_wiki_projects.py -q` +Expected: новые FAIL. + +- [ ] **Step 3: Реализовать `_active_same_root`, проверку в `check_batch` и поиск в `enqueue_sync`** + +Текст ошибки `check_batch`: `f"команда {i}: у проекта {project} уже идёт синхронизация корня {root} (пакет #{bid})"`. +`enqueue_sync` и `check_batch` меняются в одном коммите (design 7). + +- [ ] **Step 4: Запустить wiki-тесты** + +Run: `uv run pytest tests/test_wiki_commands.py tests/test_wiki_projects.py tests/test_wiki_api.py tests/test_wiki_sync.py -q` +Expected: PASS. + +- [ ] **Step 5: Коммит** + +```bash +git add src/sdlc_hub/modules/wiki/commands.py tests/test_wiki_commands.py tests/test_wiki_projects.py +git commit -m "Вики: одна синхронизация на корень — повтор не ставит вторую (#135, T8.3)" +``` + +### Task 3: Вложение без файла и признак картинки — T8.2, `image` + +**Files:** +- Modify: `src/sdlc_hub/modules/wiki/attachments.py` (`request_fetch`, новая `is_image`), `src/sdlc_hub/modules/wiki/api.py:77-79` (страница), `:125` (fetch) +- Test: `tests/test_wiki_api.py` + +**Interfaces:** +- Produces: `attachments.request_fetch(conn, attachment_id: int, author: str | None, files_dir) -> dict` — новый обязательный параметр `files_dir` (путь или `None`; `None` — проверка файла пропускается). +- Produces: `attachments.is_image(url: str | None) -> bool` — последний сегмент `urlsplit(url).path`, `unquote`, `.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"))`. +- Produces: `GET /wiki/pages/{code}` → `attachments: [{id, name, state, size, error, image}]` (Task 5 читает `image`). + +- [ ] **Step 1: Написать падающие тесты** + +```python +def test_fetch_ready_file_missing_requeues(api): # T8.2 + aid = synced(api) + api.client.post(f"/wiki/attachments/{aid}/fetch", {}) + sha = upload(api, b"hello")["sha256"] + api.report(fetch_cmd(api), "done", {"file": sha, "size": 5}) + (api.files_dir / sha[:2] / sha).unlink() # путь файла — как в core.files; сверить + r = api.client.post(f"/wiki/attachments/{aid}/fetch", {}) + assert r["state"] == "fetching" + assert fetch_cmd(api)["params"]["attachment_id"] == aid + +def test_fetch_ready_file_present_no_batch(api): # Review Focus 4 + aid = synced(api) + api.client.post(f"/wiki/attachments/{aid}/fetch", {}) + sha = upload(api, b"hello")["sha256"] + api.report(fetch_cmd(api), "done", {"file": sha, "size": 5}) + n = len(api.rows("SELECT id FROM batch")) + assert api.client.post(f"/wiki/attachments/{aid}/fetch", {})["state"] == "ready" + assert len(api.rows("SELECT id FROM batch")) == n + +def test_page_attachment_image_flag(api): + synced(api, attachments=[{"ref": FILE_REF, "url": FILE_URL, "name": "attachment"}, + {"ref": IMG_REF, "url": "https://eva.example.test" + IMG_REF, "name": "Схема без расширения"}]) + atts = api.client.get("/wiki/pages/DOC-3", project="AWPCA")["attachments"] + assert [a["image"] for a in atts] == [False, True] + +@pytest.mark.parametrize("url,expected", [("https://e/files/a/x.PNG?version=1", True), ("https://e/files/a/x.svg", False), + ("https://e/files/a/%D1%81.jpeg", True), (None, False), ("https://e/files/a/x.png.meta/", False)]) +def test_is_image(url, expected): + assert attachments.is_image(url) is expected +``` + +Ключ ответа `POST /agent/files`, путь файла в `files/` и доступ к `files_dir` в фикстуре `api` — по `core/files.py` и +`tests/test_wiki_sync.py`; исполнитель подставляет фактические. HTML страницы в `test_page_attachment_image_flag` +должен содержать оба `ref` (`` и ``) — передать `name_html`. + +- [ ] **Step 2: Запустить — тесты падают** + +Run: `uv run pytest tests/test_wiki_api.py -q` +Expected: новые FAIL. + +- [ ] **Step 3: Реализовать** + - `request_fetch`: ветка «ответить как есть» для `ready` — только если `files_dir is None or files.size(files_dir, row["file"]) is not None`. + - `h_attachment_fetch` передаёт `hub.files_dir`; прочие вызовы `request_fetch` (grep) — тоже. + - Страница: в выборке вложений добавить `url`, в ответ — `image: attachments.is_image(url)`, `url` в ответ страницы не добавлять. + +- [ ] **Step 4: Запустить wiki-тесты** + +Run: `uv run pytest tests/test_wiki_api.py tests/test_wiki_mcp.py -q` +Expected: PASS (прежний `test_status_tree_page` обновить: в ожидаемом словаре вложения — `"image": False`). + +- [ ] **Step 5: Коммит** + +```bash +git add src/sdlc_hub/modules/wiki/attachments.py src/sdlc_hub/modules/wiki/api.py tests/test_wiki_api.py +git commit -m "Вики: вложение без файла скачивается заново, признак картинки у вложений страницы (#135, T8.2)" +``` + +### Task 4: MCP — значения из Eva одной строкой (T9.3) + +**Files:** +- Modify: `src/sdlc_hub/modules/wiki/mcp.py` (новая `_line`, места вывода из design 8) +- Test: `tests/test_wiki_mcp.py` + +**Interfaces:** +- Produces: `mcp._line(v) -> str` — `core.value(v)`, затем `re.sub(r"\s*[\r\n]+\s*", " ", …)`. + +- [ ] **Step 1: Написать падающие тесты** (маршруты `Fake` — как в соседних тестах `test_page_chunks`, `test_search`, `test_tree_depth_and_subtree`) + +```python +def test_page_header_newlines_collapsed(hub): + hub({"/wiki/pages/DOC-3": {"code": "DOC-3", "title": "Статусы\nпуть: поддельный", "path": "Корень/\nСтатусы", + "url": "https://eva/x", "synced_at": "2026-10-24T10:00:00+00:00", "markdown": "строка1\nстрока2", + "attachments": [{"id": 7, "name": "a\nb.png", "state": "none", "size": None, "error": None, "image": True}]}}) + out = asyncio.run(...) # вызов page как в test_page_chunks + lines = out.splitlines() + assert lines[0] == "DOC-3 — Статусы путь: поддельный" + assert sum(l.startswith("путь:") for l in lines) == 1 + assert " #7 a b.png (none)" in lines and "строка1\nстрока2" in out + +def test_search_and_tree_newlines_collapsed(hub): + # search: title "A\nB", path "P\nQ", snippet "x\ny" → одна строка «… A B — P Q» и строка « x y» + # tree: title "T\nU" → «DOC-2 T U (200 симв.)» +``` + +Исполнитель дописывает второй тест конкретными `Fake`-маршрутами и утверждениями из комментария — значения заданы. + +- [ ] **Step 2: Запустить — тесты падают** + +Run: `uv run pytest tests/test_wiki_mcp.py -q` +Expected: новые FAIL. + +- [ ] **Step 3: Реализовать `_line` и применить** — `tree`, `page` (шапка и вложения), `_candidates` (названия и `title` запроса), `search`, `attachment` (имя, код страницы), `_summary`/`status` (название корня, ошибка). Тело страницы не трогать. + +- [ ] **Step 4: Запустить** + +Run: `uv run pytest tests/test_wiki_mcp.py -q` +Expected: PASS. + +- [ ] **Step 5: Коммит** + +```bash +git add src/sdlc_hub/modules/wiki/mcp.py tests/test_wiki_mcp.py +git commit -m "MCP вики: значения из Eva одной строкой (#135, T9.3)" +``` + +### Task 5: Картинки в тексте страницы «Вики» + +**Files:** +- Modify: `web/src/modules/wiki/api/queries.ts` (`WikiAttachment.image`), `web/src/modules/wiki/download.ts` (режим `show`, хранилище картинок), `web/src/modules/wiki/PageView.tsx` (`AttachmentLink`), `web/src/modules/wiki/wiki.css` +- Test: `web/src/modules/wiki/download.test.tsx`, `web/src/modules/wiki/WikiPage.test.tsx` + +**Interfaces:** +- Consumes: `GET /wiki/pages/{code}` → `attachments[].image: boolean` (Task 3). +- Produces: `useWikiDownload(project, scope, images?: WikiAttachment[])` → `{ start(id: string, mode?: "save" | "show"): void; status(id): DownloadStatus; image(id): string | null }`; `image` — objectURL картинки или `null`. Объект стабилен при неизменных `start/status/image` (как сейчас). +- Тип blob: `new Blob([blob], { type: MIME_BY_EXT[ext] ?? "application/octet-stream" })`, `MIME_BY_EXT = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", bmp: "image/bmp" }`; расширение — из `name`, если его нет — `image/*` не ставится, браузер определит сам (blob без типа). + +- [ ] **Step 1: Написать падающие тесты** (моки `api` и `URL.createObjectURL`/`revokeObjectURL` — как в `download.test.tsx`) + +```tsx +it("ready-картинка при открытии страницы показывается без fetch", async () => { + // страница DOC-3, attachments: [{id: 7, name: "scheme.png", state: "ready", image: true}] + // ожидание: GET /wiki/attachments/7/content вызван, POST …/fetch — нет; в DOM img с src blob:… и alt «scheme.png»; + // под картинкой кнопка «scheme.png» (сохранение) +}); +it("клик по нескачанной картинке: fetch, опрос, картинка вместо сохранения", async () => { + // state none → клик → POST /wiki/attachments/7/fetch → ready → GET content; img в DOM; save (a.download) не вызван +}); +it("не картинка сохраняется как раньше", async () => { + // {id: 8, name: "model.xlsx", state: "ready", image: false} → клик → a.download === "model.xlsx", img нет +}); +it("content 409 у ready-картинки — остаётся ссылка без ошибки, клик ставит скачивание", async () => {}); +it("смена страницы: поздний content не показывается, objectURL отозваны", async () => { // Review Focus 5 +}); +it("появление картинки не сбрасывает busy другого вложения", async () => {}); +``` + +Исполнитель дописывает тела по образцу соседних тестов `download.test.tsx`/`WikiPage.test.tsx`; ожидания заданы в комментариях. + +- [ ] **Step 2: Запустить — тесты падают** + +Run: `cd web && npx vitest run src/modules/wiki` +Expected: новые FAIL. + +- [ ] **Step 3: Реализовать** по design 10: автозапрос `content` один раз на `ready`-картинку за жизнь `scope`; режим `show` кладёт objectURL в ту же карту; сброс карты и `revokeObjectURL` при смене `project/scope` и размонтировании; `AttachmentLink` — `{name}` и кнопка `save` под ней, без картинки — кнопка `show` для `image`, `save` иначе. CSS: `.wiki-att-img{display:block}` и `.wiki-att-img img{display:block;max-width:100%;height:auto;border-radius:var(--radius-md)}`. + +- [ ] **Step 4: Запустить фронт целиком** + +Run: `cd web && npm run lint && npm test` +Expected: lint чистый, все тесты PASS. + +- [ ] **Step 5: Коммит** + +```bash +git add web/src/modules/wiki +git commit -m "Вики: скачанные картинки показываются в тексте страницы (#135)" +``` + +### Завершение + +- [ ] `make check` — весь набор зелёный. +- [ ] Живая проверка на тестовом хабе или после выкатки (за владельцем): картинка `vgo-matching-1.png` (AWPCA, в `ready`) видна в тексте своей страницы.