diff --git a/.opencode/scripts/pipeline-status.py b/.opencode/scripts/pipeline-status.py index 515a0b9..2d999e4 100644 --- a/.opencode/scripts/pipeline-status.py +++ b/.opencode/scripts/pipeline-status.py @@ -51,6 +51,7 @@ _MEMORY_BASE = os.environ.get( MEMORY_DIR = Path(_MEMORY_BASE) / "repos" HANDOFF_DIR = REPO_ROOT / "docs" / "handoff" ADR_DIR = REPO_ROOT / "docs" / "decisions" +PROJECT_MAP_DIR = REPO_ROOT / "docs" / "project-map" REQUIRED_SECTIONS = ["## Что сделано", "## Почему", "## Pending", "## Watch out"] @@ -264,13 +265,15 @@ def check_implement(pr_number: int) -> PhaseResult: def check_docs(pr_number: int) -> PhaseResult: - """Phase 3: DOCS — handoff valid (4 sections) + mandatory ADR + docs-reviewer marker. + """Phase 3: DOCS — handoff valid (4 sections) + mandatory ADR + project map + + docs-reviewer marker. Deterministic checks: 1. Handoff file `docs/handoff/pr-N-*.md` exists. 2. 4 sections present in handoff content. 3. ADR file `docs/decisions/*-pr-N-*.md` exists. - 4. PR comment with heading `## Docs Review Summary` from docs-reviewer (proves it ran). + 4. Project map `docs/project-map/README.md` exists (ADR-028 contract). + 5. PR comment with heading `## Docs Review Summary` from docs-reviewer (proves it ran). Without the docs-reviewer comment, DOCS is NOT_DONE — subagent may have written handoff+ADR without docs-reviewer actually validating them. Symmetric to @@ -289,6 +292,10 @@ def check_docs(pr_number: int) -> PhaseResult: if adr_result.status != PhaseStatus.DONE: return adr_result + pm_result = check_project_map() + if pm_result.status != PhaseStatus.DONE: + return pm_result + return _check_docs_reviewer_comment(pr_number) @@ -324,6 +331,29 @@ def check_adr(pr_number: int) -> PhaseResult: ) +def check_project_map() -> PhaseResult: + """Phase 3 part: docs/project-map/README.md exists (mandatory project map). + + Minimal guard against silent docs-reviewer failure. Does NOT validate + frontmatter or module-level files — see ADR-028 for the contract. + """ + if not PROJECT_MAP_DIR.exists(): + return PhaseResult( + PhaseStatus.NOT_DONE, + f"директория {PROJECT_MAP_DIR} не найдена — " + "docs-reviewer должен создать initial README.md (docs-reviewer.md:82-84)", + ) + readme = PROJECT_MAP_DIR / "README.md" + if readme.exists(): + size = readme.stat().st_size + return PhaseResult(PhaseStatus.DONE, f"project map: README.md ({size} bytes)") + return PhaseResult( + PhaseStatus.NOT_DONE, + "docs/project-map/README.md не найден — " + "docs-reviewer должен создать initial map (docs-reviewer.md:244)", + ) + + def check_ci(pr_number: int) -> PhaseResult: """Phase 4: CI — latest CI run on PR branch completed & success. diff --git a/docs/decisions/028-pr-67-project-map-contract.md b/docs/decisions/028-pr-67-project-map-contract.md new file mode 100644 index 0000000..2b38b5e --- /dev/null +++ b/docs/decisions/028-pr-67-project-map-contract.md @@ -0,0 +1,73 @@ +# ADR-028: Project map contract (mandatory README.md, aspirational modules) + +## Статус + +Accepted (2026-07-25) + +## Контекст + +Контракт `docs/project-map/` в репо `slaid098/opencode-config` размазан по 3 +файлам с внутренними несоответствиями: + +- `.opencode/agents/docs-reviewer.md:158-159,191` — обязывает + docs-reviewer обновлять карту при структурных изменениях и предлагает + один commit message. +- `.opencode/skills/get-project-map/SKILL.md:25-27` — описывает структуру + карты (README.md + модульные файлы per module) и frontmatter + `last_updated`. +- `.opencode/agents/reviewer.md:199-205` — требует от code-reviewer проверять + актуальность карты и предлагает *другой* commit message для той же + операции (несоответствие с docs-reviewer.md). + +Фактическое состояние карты: только `README.md` (~140 строк, ASCII-дерево +структуры репо) без frontmatter и без модульных файлов. Полный контракт +(модульные файлы per module) никогда не соблюдался — aspirational документ, +не enforcement. + +`pipeline-status.py:check_docs` (DOCS phase) валидировал handoff (4 секции), +ADR (filename pattern) и docs-reviewer comment (heading `## Docs Review +Summary`), но **не валидировал `docs/project-map/`**. Это позволяло PR проходить +DOCS-фазу даже при «тихом провале» docs-reviewer (забыл обновить карту при +структурных изменениях) — детерминированный guard отсутствовал. Зафиксировано +в memory `technical/pipeline-status-no-project-map-check.md`. + +## Решение + +Минимальный guard в `pipeline-status.py` — новая функция `check_project_map()` +проверяет существование `docs/project-map/README.md` (через +`PROJECT_MAP_DIR.exists()` + `README.md.exists()`). Встроена в `check_docs` +между `check_adr` и `_check_docs_reviewer_comment` (short-circuit на +NOT_DONE). Mandatory для pipeline-driven репо: docs-reviewer автосоздаёт +initial README.md при отсутствии каталога (`docs-reviewer.md:82-84`), поэтому +guard не блокирует первый PR и не требует ручной bootstrap-операции. + +Контракт ослаблен под фактическое состояние: + +- **README.md обязателен** — единственное, что проверяется детерминированно. + Возвращает DONE с размером файла в detail, либо NOT_DONE с указанием что + docs-reviewer должен создать (с ссылками на строки docs-reviewer.md). +- **Модульные файлы per module и frontmatter `last_updated`** — aspirational, + НЕ enforced. Полный контракт остаётся в `get-project-map/SKILL.md` как + рекомендация для docs-reviewer, но не блокирует пайплайн. + +Возвращает только DONE/NOT_DONE (без AMBIGUOUS) — проверка файловой системы +детерминирована, нет внешних API вызовов. + +## Альтернативы + +- **Полный контракт (модульные файлы per module)** — отклонено: потребовало + бы создания ~12 новых файлов (по одному на модуль) для существующего репо, + блокируя пайплайн до их создания. Фактическое состояние никогда не + соответствовало этому контракту — enforcement сломал бы все открытые PR. + +- **Опциональность через env-flag `OPENCODE_REQUIRE_PROJECT_MAP`** — + отклонено: docs-reviewer уже автосоздаёт initial README.md при отсутствии + каталога (`docs-reviewer.md:82-84`), feature-flag избыточен — guard + никогда не блокирует корректно настроенный pipeline-driven репо. Env-flag + добавил бы конфигурационную поверхность без выгоды. + +- **Diff-based проверка (карта обновлена в PR через `gh pr view --json + files`)** — отклонено: сложнее в реализации (парсинг diff, фильтр по путям), + не гарантирует валидность содержимого (файл может быть изменён, но остаться + пустым/устаревшим). Существование README.md — более сильный guard (файл + присутствует), diff-based — слабее (файл изменён, но может быть невалиден). \ No newline at end of file diff --git a/docs/handoff/pr-67-check-project-map.md b/docs/handoff/pr-67-check-project-map.md new file mode 100644 index 0000000..8bdda6d --- /dev/null +++ b/docs/handoff/pr-67-check-project-map.md @@ -0,0 +1,95 @@ +--- +pr: 67 +title: feat(pipeline): add check_project_map to DOCS phase +--- + +## Что сделано + +- **`.opencode/scripts/pipeline-status.py`** — 3 точечные правки: + - Константа `PROJECT_MAP_DIR = REPO_ROOT / "docs" / "project-map"` (после + `ADR_DIR`, строка 54). + - Функция `check_project_map()` (после `check_adr`) — проверяет + `PROJECT_MAP_DIR.exists()` + `README.md.exists()`, возвращает + DONE/NOT_DONE (без AMBIGUOUS, чисто файловая система). Шаблон по образцу + `check_adr`. + - В `check_docs` вставлен вызов между `check_adr` и + `_check_docs_reviewer_comment` (short-circuit на NOT_DONE). Docstring + `check_docs` обновлён (теперь 5 детерминированных проверок). +- **`tests/test_pipeline_status_project_map.py`** — новый файл, 3 unit-теста + (по образцу `test_pipeline_status_adr.py`): + - `test_project_map_exists` — README.md есть → DONE, "README.md" в detail. + - `test_project_map_dir_missing` — директория отсутствует → NOT_DONE, + "директория" в detail. + - `test_project_map_readme_missing` — директория есть, README.md нет → + NOT_DONE, "README.md не найден" в detail. + Загрузка скрипта под уникальным module name `pipeline_status_project_map` + (не коллидирует с `pipeline_status` из других тестов). +- **`tests/test_pipeline_status.py`** — критическая правка существующих + тестов: + - Добавлен helper `make_project_map(tmp_path)` рядом с `make_handoff` — + создаёт `tmp_path/project-map/README.md`. + - Во все 10 `test_check_docs_*` тестов добавлен + `monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path))`. + Без этого новая под-проверка возвращала NOT_DONE и ломала assertions + (7 тестов с валидным ADR доходили до check_project_map). +- **ADR-028** (`docs/decisions/028-pr-67-project-map-contract.md`) — + фиксирует контракт project-map: README.md обязателен (mandatory, enforced), + модульные файлы и frontmatter `last_updated` — aspirational (не enforced). + Альтернативы: полный контракт (отклонено — блокирует пайплайн, ~12 файлов), + env-flag (отклонено — docs-reviewer автосоздаёт), diff-based (отклонено — + сложнее, не гарантирует валидность). +- **`docs/project-map/README.md`** — секция Update Protocol дополнена строкой + про новый guard; tree обновлён (новый тест-файл + тег PR#67 на + pipeline-status.py). + +## Почему + +Детерминированный guard для project-map отсутствовал — +`pipeline-status.py:check_docs` проверял handoff, ADR и docs-reviewer +comment, но не валидировал `docs/project-map/`. Это позволяло PR проходить +DOCS-фазу даже при «тихом провале» docs-reviewer (забыл обновить карту при +структурных изменениях). Memory `technical/pipeline-status-no-project-map-check.md` +зафиксировала эту дыру. + +Контракт карты был размазан по 3 файлам (`docs-reviewer.md`, +`get-project-map/SKILL.md`, `reviewer.md`) с несоответствиями (2 разных commit +message для одной операции). Фактическое состояние — только README.md без +frontmatter и модульных файлов. ADR-028 ослабляет контракт под фактическое +состояние (README.md mandatory, модульные файлы aspirational) и фиксирует +единый source of truth. Полный контракт остаётся в `get-project-map/SKILL.md` +как рекомендация. + +Локальная верификация: 404 теста прошли, coverage 89.19% ≥ 80% +(pipeline-status.py покрыт на 88%). + +## Pending + +- После merge: фаза MEMORY — обновить memory + `technical/pipeline-status-no-project-map-check.md` секцией Closure (guard + добавлен в PR#67, контракт зафиксирован в ADR-028). +- ADR filename переименован с placeholder `` на `028-pr-67-project-map-contract.md` + после create_pr (коммит `docs(handoff): set PR number`). + +## Watch out + +- **10 существующих тестов правлены** — `test_check_docs_*` в + `test_pipeline_status.py` теперь требуют `make_project_map(tmp_path)` + + monkeypatch `PROJECT_MAP_DIR`. Если добавить новый `test_check_docs_*` без + этого — упадёт на NOT_DONE от `check_project_map`. Паттерн: копируй строку + `monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path))` из + соседнего теста. +- **Уникальный module name в новом тесте** — `pipeline_status_project_map` + (не `pipeline_status`). Если использовать `pipeline_status`, произойдёт + коллизия с `test_pipeline_status.py`/`test_pipeline_status_adr.py` которые + грузят скрипт под тем же именем — `sys.modules` уже занят, тесты + конфликтуют. Шаблон: новый test-файл для под-проверки → уникальный module + name. +- **Контракт ослаблен намеренно** — ADR-028 явно фиксирует что модульные + файлы и frontmatter `last_updated` НЕ enforced. Если будущий PR захочет + ужесточить контракт (полный enforcement) — это будет breaking change, + требующий создания ~12 файлов для существующего репо. См. альтернативу в + ADR-028. +- **scaffold-handoff.sh нумерация** — скрипт считает `ls docs/decisions | + grep -E '^[0-9]{3}-' | wc -l + 1`. Если ADR-028 уже существует, следующий + вызов даст 029. При создании ADR вручную + через scaffold в одном PR — + scaffold создаст дубликат с +1 номером (нужно удалить вручную). \ No newline at end of file diff --git a/docs/project-map/README.md b/docs/project-map/README.md index 9fbdf19..eafa7ed 100644 --- a/docs/project-map/README.md +++ b/docs/project-map/README.md @@ -54,7 +54,7 @@ opencode-config/ │ │ ├── check-adr-refs.py # ADR cross-reference validator (adr-check.yml) │ │ ├── check-permissions.py # Permissions validator (permissions-check.yml) │ │ ├── observability.py # OTel spans for tools -│ │ ├── pipeline-status.py # 7-phase oracle (gh PR + CI polling, NEXT_ACTIONS with subagent_type+template) — PR#42 +│ │ ├── pipeline-status.py # 7-phase oracle (gh PR + CI polling, NEXT_ACTIONS with subagent_type+template) — PR#42, PR#67 (check_project_map guard) │ │ ├── scaffold-handoff.sh # Scaffold handoff + ADR stubs │ │ ├── setup-memory.sh # opencode-memory bootstrap (deterministic 6-step flow, idempotent) — PR#36 │ │ ├── spec-status.py # 9-phase spec oracle @@ -100,6 +100,7 @@ opencode-config/ │ ├── test_pipeline_status_adr.py │ ├── test_pipeline_status_ci.py │ ├── test_pipeline_status_next_actions.py # NEXT_ACTIONS subagent_type+template per phase (25 tests) — PR#42 +│ ├── test_pipeline_status_project_map.py # check_project_map guard (README.md mandatory, ADR-028) — PR#67 │ ├── test_pipeline_status_tool.py │ ├── test_pipeline_status_tool.ts # TS wrapper test (mjs loader) │ ├── test_post_docs_review_tool.py # .opencode/tools/post-docs-review.ts (via _ts_loader.mjs exec_stub_json; +repo cases) — PR#46, PR#65 @@ -138,3 +139,5 @@ opencode-config/ ## Update Protocol Updated by docs-reviewer subagent on each PR. Reflects tracked files only (`git ls-files`). + +Guarded by `pipeline-status.py:check_project_map` — README.md existence is mandatory. diff --git a/tests/test_pipeline_status.py b/tests/test_pipeline_status.py index fe4b870..98742ae 100644 --- a/tests/test_pipeline_status.py +++ b/tests/test_pipeline_status.py @@ -83,6 +83,17 @@ def make_handoff( return handoff +def make_project_map(tmp_path: Path) -> Path: + """Create docs/project-map/README.md in tmp_path, return the dir path. + + Satisfies the mandatory ``check_project_map`` guard (ADR-028 contract). + """ + pm_dir = tmp_path / "project-map" + pm_dir.mkdir(parents=True, exist_ok=True) + (pm_dir / "README.md").write_text("# Project Map\n") + return pm_dir + + # ── parse_remote_url ──────────────────────────────────────────────────────── @@ -304,6 +315,7 @@ def test_check_docs_done(tmp_path, monkeypatch): (adr_dir / "002-pr-46-test.md").write_text("# ADR-002") monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path) monkeypatch.setattr(ps, "ADR_DIR", adr_dir) + monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path)) make_handoff(tmp_path, 46) monkeypatch.setattr( ps, @@ -325,6 +337,7 @@ def test_check_docs_done(tmp_path, monkeypatch): def test_check_docs_not_done_no_handoff(tmp_path, monkeypatch): monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path) + monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path)) result = ps.check_docs(46) assert result.status == ps.PhaseStatus.NOT_DONE assert "не найден" in result.detail @@ -334,6 +347,7 @@ def test_check_docs_not_done_no_adr(tmp_path, monkeypatch): """ADR missing → DOCS phase NOT_DONE (mandatory).""" monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path) monkeypatch.setattr(ps, "ADR_DIR", tmp_path / "decisions") + monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path)) make_handoff(tmp_path, 46) result = ps.check_docs(46) assert result.status == ps.PhaseStatus.NOT_DONE @@ -343,6 +357,7 @@ def test_check_docs_not_done_no_adr(tmp_path, monkeypatch): def test_check_docs_not_done_missing_sections(tmp_path, monkeypatch): monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path) monkeypatch.setattr(ps, "ADR_DIR", tmp_path / "decisions") + monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path)) make_handoff(tmp_path, 46, sections=["## Что сделано", "## Почему"]) result = ps.check_docs(46) assert result.status == ps.PhaseStatus.NOT_DONE @@ -356,6 +371,7 @@ def test_check_docs_done_with_adr(tmp_path, monkeypatch): (adr_dir / "002-pr-46-test.md").write_text("# ADR-002") monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path) monkeypatch.setattr(ps, "ADR_DIR", adr_dir) + monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path)) make_handoff(tmp_path, 46, extra="Архитектурное изменение, см. ADR-002.") monkeypatch.setattr( ps, @@ -382,6 +398,7 @@ def test_check_docs_not_done_no_comment(tmp_path, monkeypatch): (adr_dir / "002-pr-46-test.md").write_text("# ADR-002") monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path) monkeypatch.setattr(ps, "ADR_DIR", adr_dir) + monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path)) make_handoff(tmp_path, 46) monkeypatch.setattr( ps, @@ -400,6 +417,7 @@ def test_check_docs_not_done_comment_without_marker(tmp_path, monkeypatch): (adr_dir / "002-pr-46-test.md").write_text("# ADR-002") monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path) monkeypatch.setattr(ps, "ADR_DIR", adr_dir) + monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path)) make_handoff(tmp_path, 46) monkeypatch.setattr( ps, @@ -426,6 +444,7 @@ def test_check_docs_false_positive_reviewer_comment(tmp_path, monkeypatch): (adr_dir / "002-pr-46-test.md").write_text("# ADR-002") monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path) monkeypatch.setattr(ps, "ADR_DIR", adr_dir) + monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path)) make_handoff(tmp_path, 46) monkeypatch.setattr( ps, @@ -452,6 +471,7 @@ def test_check_docs_ambiguous_api_error(tmp_path, monkeypatch): (adr_dir / "002-pr-46-test.md").write_text("# ADR-002") monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path) monkeypatch.setattr(ps, "ADR_DIR", adr_dir) + monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path)) make_handoff(tmp_path, 46) monkeypatch.setattr( ps, @@ -470,6 +490,7 @@ def test_check_docs_done_with_fixed_verdict(tmp_path, monkeypatch): (adr_dir / "002-pr-46-test.md").write_text("# ADR-002") monkeypatch.setattr(ps, "HANDOFF_DIR", tmp_path) monkeypatch.setattr(ps, "ADR_DIR", adr_dir) + monkeypatch.setattr(ps, "PROJECT_MAP_DIR", make_project_map(tmp_path)) make_handoff(tmp_path, 46) comment_body = ( "## Docs Review Summary\\n- Handoff: fixed: added Pending\\n\\n### Verdict: FIXED" diff --git a/tests/test_pipeline_status_project_map.py b/tests/test_pipeline_status_project_map.py new file mode 100644 index 0000000..9674069 --- /dev/null +++ b/tests/test_pipeline_status_project_map.py @@ -0,0 +1,55 @@ +"""Tests for .opencode/scripts/pipeline-status.py — mandatory project map. + +Verifies the deterministic check_project_map: docs/project-map/README.md is +required for every pipeline-driven PR (ADR-028). Loaded under a unique module +name (``pipeline_status_project_map``) so it does not collide with other test +modules that load the same script as ``pipeline_status``. +""" + +import importlib.util +import sys +from pathlib import Path + +SCRIPT_PATH = ( + Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "pipeline-status.py" +) +spec = importlib.util.spec_from_file_location("pipeline_status_project_map", SCRIPT_PATH) +ps = importlib.util.module_from_spec(spec) +sys.modules["pipeline_status_project_map"] = ps +spec.loader.exec_module(ps) + + +def test_project_map_exists(tmp_path, monkeypatch): + """PROJECT_MAP_DIR + README.md → DONE, "README.md" в detail.""" + pm_dir = tmp_path / "project-map" + pm_dir.mkdir() + (pm_dir / "README.md").write_text("# Project Map\n") + monkeypatch.setattr(ps, "PROJECT_MAP_DIR", pm_dir) + + result = ps.check_project_map() + + assert result.status == ps.PhaseStatus.DONE + assert "README.md" in result.detail + + +def test_project_map_dir_missing(tmp_path, monkeypatch): + """PROJECT_MAP_DIR не существует → NOT_DONE, "директория" в detail.""" + missing_dir = tmp_path / "does-not-exist" + monkeypatch.setattr(ps, "PROJECT_MAP_DIR", missing_dir) + + result = ps.check_project_map() + + assert result.status == ps.PhaseStatus.NOT_DONE + assert "директория" in result.detail + + +def test_project_map_readme_missing(tmp_path, monkeypatch): + """Директория есть, README.md нет → NOT_DONE, "README.md не найден" в detail.""" + pm_dir = tmp_path / "project-map" + pm_dir.mkdir() + monkeypatch.setattr(ps, "PROJECT_MAP_DIR", pm_dir) + + result = ps.check_project_map() + + assert result.status == ps.PhaseStatus.NOT_DONE + assert "README.md не найден" in result.detail