sdlc-hub/docs/superpowers/plans/2026-10-27-wiki-package-d.md
Andrey Epifancev a83dd27733 План: ветка пакета D (#135)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:39:13 +04:00

388 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Пакет 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.
**Ветка:** `feat/135-wiki-package-d` (worktree `.wt/wiki-d`).
**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. Ссылка на страницу, внутри которой и картинка-вложение, и текст (`<a href="project/Document/DOC-1"><img src=…>Схема</a>`) — вложение перед ссылкой, текст ссылки «Схема». Тест в 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'<p><a href="{FILE_REF}"><span><img src="{THUMB_REF}"></span></a> текст</p>', [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'<p><a href="{FILE_REF}"><img src="{IMG_REF}"></a></p>', [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'<p><a href="project/Document/DOC-1"><img src="{IMG_REF}"></a></p>', [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'<p><a href="project/Document/DOC-1"><img src="{IMG_REF}">Схема</a></p>', [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'<p><a href="https://ex.com/x"><img src="{IMG_REF}"></a></p>', [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'<p><img src="{IMG_REF}"></p>', [dict(IMG, name="first"), dict(IMG, url="u2", name="second")], [])
assert atts == [{"url": IMG_URL, "name": "first"}]
md, _ = convert.to_markdown('<p><a href="project/Document/DOC-1">т</a></p>', [], [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('<p><a href="http://ex.com/a b(c).html">док</a></p>', [], [])
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('<p><a href="http://ex.com/a%20b.html">док</a></p>', [], [])
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('<p><a href="project/Document/DOC-1">путь C:\\</a></p>', [], [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('<p><a href="http://ex.com/">путь C:\\</a></p>', [], [])
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` (`<a href=FILE_REF>` и `<img src=IMG_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` — `<span className="wiki-att-img"><img src alt={name} /></span>` и кнопка `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`) видна в тексте своей страницы.