"""ORCH-011 (FR-7 / AC-1…AC-12): анти-дрейф контур витрины системы `docs/overview/`. Структурные проверки витрины (ADR-001 D6, образец — `tests/test_lite_setup_doc.py` / `test_bundled_setup_doc.py`): 10 файлов D1 существуют и непусты (TC-01); индекс несёт маршруты трёх аудиторий и норматив сопровождения, из него достижимы все части (TC-02); бизнес-часть несёт 5 обязательных смысловых разделов и ≥5 сценариев (AC-2); карта стадий и гейтов derive-сверяется импортом `src.stages.STAGE_TRANSITIONS` и `src.qg.checks.QG_CHECKS` — не статичным списком (TC-03/TC-04, паттерн ORCH-091); полнота агентов derive из glob `.openclaw/agents/*.md`, таблица эффортов — из class-default'ов `src.config.Settings` (TC-05, ORCH-41/81); все относительные ссылки резолвятся, обязательные golden-source ссылки AC-6 присутствуют (TC-06); гигиена — полнотекстовый FORBIDDEN-скан (импорт из `tests/test_no_host_hardcodes.py`, не копия) + секрет-эвристика + запрет вне-репозиторных путей (TC-07, AC-12); слайдо-источник валидируется каноническим парсером `parse_slides` из `scripts/build_presentation.py` (TC-08, один парсер — один источник истины о формате, прецедент `test_bootstrap_script.py`); NFR-2 машинно: `pptx` отсутствует в `requirements*` / `Dockerfile` (TC-09); указатели README/CLAUDE/CHANGELOG обновлены (TC-10). Детерминировано: без сети/LLM/subprocess. Новый QG НЕ регистрируется (ТЗ §6) — модуль исполняется существующими гейтами (`check_ci_green` / `check_tests_passed`). """ import ast import importlib.util import re import sys from pathlib import Path from src.config import Settings from src.qg.checks import QG_CHECKS from src.stages import STAGE_TRANSITIONS # Один источник истины запрещённых боевых литералов (ORCH-101 AC-7). from tests.test_no_host_hardcodes import FORBIDDEN REPO_ROOT = Path(__file__).resolve().parents[1] OVERVIEW = REPO_ROOT / "docs" / "overview" AGENTS_DIR = REPO_ROOT / ".openclaw" / "agents" BUILD_SCRIPT = REPO_ROOT / "scripts" / "build_presentation.py" # Нормативный состав витрины (ADR-001 D1: плоский каталог, 10 файлов). SHOWCASE_FILES: tuple[str, ...] = ( "README.md", "business.md", "tech-architecture.md", "tech-pipeline.md", "tech-agents.md", "tech-data-model.md", "tech-integrations.md", "tech-quality-security.md", "tech-observability.md", "presentation.md", ) # Под-гейты ребра deploy-staging→deploy: фактические имена реестра QG_CHECKS в # нормативном порядке security → merge → coverage → image-freshness (adr-0029). SUBGATES_IN_ORDER: tuple[str, ...] = ( "check_security_gate", "check_branch_mergeable", "check_coverage_gate", "check_staging_image_fresh", ) # Обязательные golden-source ссылки витрины (AC-6), repo-relative POSIX. MANDATORY_LINK_TARGETS: tuple[str, ...] = ( "docs/architecture/README.md", "docs/architecture/internals.md", "docs/_standards/PIPELINE_DOCS.md", "docs/_standards/HANDOFF_PROTOCOL.md", "docs/architecture/adr", "docs/deployment/LITE_SETUP.md", "docs/deployment/BUNDLED_SETUP.md", "docs/PRODUCT_VISION.md", "CLAUDE.md", ) # Маркдаун-ссылка: [текст](цель) — цель без пробелов. _LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)") # Секрет-эвристика (паттерн ORCH-102 D8): hex-run >= 32 / чистый alnum-run >= 40. _SECRET_HEX_RE = re.compile(r"\b[0-9a-fA-F]{32,}\b") _SECRET_ALNUM_RE = re.compile(r"\b[A-Za-z0-9]{40,}\b") # --------------------------------------------------------------------------- # helpers # --------------------------------------------------------------------------- def _read(name: str) -> str: """Текст файла витрины; падает с внятным сообщением, если файла нет.""" path = OVERVIEW / name assert path.is_file(), f"docs/overview/{name} отсутствует (D1 / AC-1)" return path.read_text(encoding="utf-8") def _stage_token_re(stage: str) -> re.Pattern: """Регэксп стадии с границами: `deploy` не матчится внутри `deploy-staging`, `review` — внутри `reviewer`/`12-review.md` (дефис и \\w исключены с обеих сторон).""" return re.compile(rf"(? int: """Позиция первого вхождения стадии (с границами) или -1.""" m = _stage_token_re(stage).search(text) return m.start() if m else -1 def _main_chain() -> list[str]: """Основная цепочка стадий, derive проходом по `next` от created (не статика).""" chain: list[str] = [] stage = "created" while stage is not None: chain.append(stage) stage = STAGE_TRANSITIONS[stage]["next"] return chain def _rel_link_targets(name: str) -> list[str]: """Относительные цели md-ссылок файла (внешние URL и якоря — мимо).""" targets: list[str] = [] for target in _LINK_RE.findall(_read(name)): if target.startswith(("http://", "https://", "mailto:", "#")): continue targets.append(target.split("#", 1)[0]) return [t for t in targets if t] def _load_build_module(): """Импорт scripts/build_presentation.py по файлу (прецедент test_bootstrap_script): top-level скрипта обязан быть stdlib-only, иначе сам импорт здесь упадёт.""" spec = importlib.util.spec_from_file_location("build_presentation", BUILD_SCRIPT) mod = importlib.util.module_from_spec(spec) # Регистрация в sys.modules ДО exec: dataclass резолвит строковые аннотации # (`from __future__ import annotations`) через sys.modules[cls.__module__]. sys.modules[spec.name] = mod spec.loader.exec_module(mod) return mod # --------------------------------------------------------------------------- # TC-01: все 10 файлов витрины существуют и непусты (AC-1 / D1). # --------------------------------------------------------------------------- def test_all_showcase_files_exist_and_nonempty(): for name in SHOWCASE_FILES: text = _read(name).strip() assert len(text) > 300, f"docs/overview/{name} подозрительно пуст (D1)" # --------------------------------------------------------------------------- # TC-02: индекс — маршруты 3 аудиторий, норматив, достижимость всех частей (AC-1/8/9). # --------------------------------------------------------------------------- def test_index_carries_three_audience_routes(): text = _read("README.md") for route in ("Я заказчик", "Я менеджер", "Я разработчик"): assert route in text, f"маршрут {route!r} отсутствует в индексе (FR-5 / AC-8)" def test_index_carries_maintenance_normative(): text = _read("README.md") assert "в том же PR" in text, ( "норматив сопровождения «изменил функциональность → обнови витрину " "в том же PR» отсутствует в индексе (FR-6 / AC-9)" ) def test_index_links_reach_every_showcase_part(): targets = {t.removeprefix("./") for t in _rel_link_targets("README.md")} for name in SHOWCASE_FILES[1:]: assert name in targets, ( f"{name} недостижим из индекса по относительной ссылке (AC-1)" ) # --------------------------------------------------------------------------- # TC-03: бизнес-часть — 5 обязательных разделов + >=5 сценариев (AC-2). # --------------------------------------------------------------------------- def test_business_part_has_five_mandatory_sections(): text = _read("business.md") for marker in ("## Проблема", "## Решение", "Что умеет", "## Ценность", "## Сценарии"): assert marker in text, f"бизнес-раздел {marker!r} отсутствует (FR-2 / AC-2)" def test_business_part_has_at_least_five_scenarios(): text = _read("business.md") count = text.count("### Сценарий") assert count >= 5, f"в business.md {count} сценариев, требуется >= 5 (FR-2 / AC-2)" # --------------------------------------------------------------------------- # TC-04: 7 тех-блоков присутствуют (через TC-01) + схема потока в блоке 1 (AC-3). # --------------------------------------------------------------------------- def test_architecture_block_carries_flow_diagram(): text = _read("tech-architecture.md") fenced = re.findall(r"```[^\n]*\n(.*?)```", text, flags=re.DOTALL) assert fenced, "tech-architecture.md не несёт ни одного fenced-блока со схемой (AC-3)" flow = [b for b in fenced if ("вебхук" in b.lower() or "webhook" in b.lower()) and "→" in b] assert flow, ( "схема потока «вебхук → очередь → агент → гейт → переход» отсутствует " "в tech-architecture.md (FR-3.1 / AC-3)" ) # --------------------------------------------------------------------------- # TC-05 (план TC-05): карта стадий = код, derive из STAGE_TRANSITIONS (AC-4). # --------------------------------------------------------------------------- def test_every_stage_from_code_is_mentioned_in_pipeline_doc(): text = _read("tech-pipeline.md") missing = [s for s in STAGE_TRANSITIONS if _first_stage_pos(text, s) == -1] assert not missing, ( f"стадии из src.stages.STAGE_TRANSITIONS отсутствуют в tech-pipeline.md " f"(AC-4): {missing}" ) def test_main_chain_order_in_pipeline_doc_matches_code(): text = _read("tech-pipeline.md") chain = _main_chain() positions = [_first_stage_pos(text, s) for s in chain] assert -1 not in positions, "стадия основной цепочки не найдена в tech-pipeline.md" assert positions == sorted(positions), ( f"порядок первых вхождений стадий {chain} в tech-pipeline.md противоречит " f"коду (AC-4): позиции {positions}" ) # --------------------------------------------------------------------------- # TC-06 (план TC-06): гейты = код; под-гейты в нормативном порядке (AC-4). # --------------------------------------------------------------------------- def test_every_exit_gate_from_code_is_named_in_pipeline_doc(): text = _read("tech-pipeline.md") exit_gates = {t["qg"] for t in STAGE_TRANSITIONS.values() if t["qg"]} missing = sorted(g for g in exit_gates if g not in text) assert not missing, f"exit-гейты рёбер не названы в tech-pipeline.md (AC-4): {missing}" def test_no_invented_gate_names_anywhere_in_showcase(): """Каждое упомянутое имя check_* существует в реестре QG_CHECKS (анти-выдумка).""" for name in SHOWCASE_FILES: mentioned = set(re.findall(r"check_[a-z_]+[a-z]", _read(name))) unknown = sorted(mentioned - set(QG_CHECKS)) assert not unknown, ( f"docs/overview/{name} упоминает несуществующие гейты (AC-4): {unknown}" ) def test_subgates_in_normative_order_and_marked_as_insets(): text = _read("tech-pipeline.md") for gate in SUBGATES_IN_ORDER: assert gate in QG_CHECKS, f"под-гейт {gate} исчез из реестра QG_CHECKS" positions = [text.find(g) for g in SUBGATES_IN_ORDER] assert -1 not in positions, ( f"под-гейт ребра deploy-staging→deploy не назван в tech-pipeline.md: " f"{[g for g, p in zip(SUBGATES_IN_ORDER, positions) if p == -1]}" ) assert positions == sorted(positions), ( "под-гейты идут не в нормативном порядке security → merge → coverage → " "image-freshness (adr-0029 / AC-4)" ) assert "не стадии" in text, ( "tech-pipeline.md обязан явно помечать под-гейты как врезки в переход, " "«не стадии» (AC-4)" ) # --------------------------------------------------------------------------- # TC-07 (план TC-07): полнота агентов derive из glob; эффорты = config (AC-5). # --------------------------------------------------------------------------- def test_every_agent_prompt_stem_is_covered(): stems = sorted(p.stem for p in AGENTS_DIR.glob("*.md")) assert stems, ".openclaw/agents/*.md не найдены — glob сломан" text = _read("tech-agents.md") missing = [s for s in stems if s not in text] assert not missing, f"роли из .openclaw/agents/ не описаны в tech-agents.md: {missing}" def test_effort_table_matches_config_class_defaults(): """Таблица модель/эффорт сходится с class-default'ами Settings (ORCH-41/81).""" text = _read("tech-agents.md") table_rows = [ln for ln in text.splitlines() if ln.strip().startswith("|")] assert table_rows, "tech-agents.md не несёт таблицы модель/эффорт (AC-5)" model_default = Settings.model_fields["agent_model_default"].default assert model_default in text, ( f"дефолтная модель {model_default!r} (config) не упомянута в tech-agents.md" ) effort_default = Settings.model_fields["agent_effort_default"].default for stem in sorted(p.stem for p in AGENTS_DIR.glob("*.md")): fld = Settings.model_fields.get(f"agent_effort_{stem}") effort = (fld.default if fld else "") or effort_default role_rows = [ln for ln in table_rows if stem in ln] assert role_rows, f"строка таблицы для роли {stem!r} отсутствует (AC-5)" assert any(f"`{effort}`" in ln for ln in role_rows), ( f"эффорт роли {stem!r} в таблице разъехался с config " f"(ожидается `{effort}`, ORCH-81)" ) # --------------------------------------------------------------------------- # TC-08 (план TC-08/TC-09): валидность ссылок + обязательные golden sources (AC-6). # --------------------------------------------------------------------------- def test_all_relative_links_resolve_to_existing_files(): broken: list[str] = [] for name in SHOWCASE_FILES: for target in _rel_link_targets(name): if not (OVERVIEW / target).resolve().exists(): broken.append(f"{name}: {target}") assert not broken, "битые относительные ссылки витрины (AC-6):\n" + "\n".join(broken) def test_mandatory_golden_source_links_present(): resolved: set[str] = set() for name in SHOWCASE_FILES: for target in _rel_link_targets(name): path = (OVERVIEW / target).resolve() if path.exists(): resolved.add(path.relative_to(REPO_ROOT).as_posix()) missing = [t for t in MANDATORY_LINK_TARGETS if t not in resolved] assert not missing, f"обязательные ссылки на golden sources отсутствуют (AC-6): {missing}" def test_no_out_of_repo_references(): """AC-12: витрина самодостаточна — никаких ссылок на вне-репозиторные пути.""" for name in SHOWCASE_FILES: text = _read(name) for needle in ("tasks/", "memory/"): assert needle not in text, ( f"docs/overview/{name} ссылается на вне-репозиторный путь " f"{needle!r} (AC-12)" ) # --------------------------------------------------------------------------- # TC-09 (план TC-13): гигиена — FORBIDDEN-скан полнотекстом + секрет-эвристика. # --------------------------------------------------------------------------- def test_showcase_carries_no_forbidden_host_literals(): """Полнотекстовый скан (шире fenced-скана ORCH-102 — обоснование ADR D6).""" offenders = [ f"{name}: {literal!r}" for name in SHOWCASE_FILES for literal in FORBIDDEN if literal in _read(name) ] assert not offenders, ( "боевые хост-литералы в витрине (NFR-3 / AC-11):\n" + "\n".join(offenders) ) def test_showcase_carries_no_secret_like_values(): offenders = [] for name in SHOWCASE_FILES: for rx in (_SECRET_HEX_RE, _SECRET_ALNUM_RE): m = rx.search(_read(name)) if m is not None: offenders.append(f"{name}: {m.group(0)[:16]}…") assert not offenders, ( "секретоподобные значения в витрине (NFR-3):\n" + "\n".join(offenders) ) def test_secret_heuristic_is_not_evergreen(): """Негативный самочек (паттерн ORCH-101/102): эвристика реально ловит.""" assert _SECRET_HEX_RE.search("token=" + "0f" * 20) is not None assert _SECRET_ALNUM_RE.search("bot" + "Q7" * 25) is not None assert _SECRET_HEX_RE.search("обычный текст витрины 8500/8501") is None assert _SECRET_ALNUM_RE.search("`check_staging_image_fresh` и `STAGE_TRANSITIONS`") is None # --------------------------------------------------------------------------- # TC-10 (план TC-10): слайдо-источник через канонический парсер (AC-7 / D4). # --------------------------------------------------------------------------- def test_build_script_toplevel_imports_are_stdlib_only(): """Ленивый импорт pptx (D4): top-level скрипта не тянет python-pptx.""" tree = ast.parse(BUILD_SCRIPT.read_text(encoding="utf-8")) top_imports: set[str] = set() for node in tree.body: if isinstance(node, ast.Import): top_imports.update(alias.name.split(".")[0] for alias in node.names) elif isinstance(node, ast.ImportFrom): top_imports.add((node.module or "").split(".")[0]) assert "pptx" not in top_imports, ( "scripts/build_presentation.py импортирует pptx на top-level — " "parse_slides обязан работать без python-pptx (D4)" ) def test_presentation_source_parses_with_canonical_parser(): mod = _load_build_module() slides = mod.parse_slides(_read("presentation.md")) assert len(slides) >= 12, ( f"слайдов {len(slides)}, требуется >= 12 (FR-4: ориентир 14–18)" ) assert [s.number for s in slides] == list(range(1, len(slides) + 1)), ( "нумерация слайдов не сквозная с 1 (D4)" ) for s in slides: assert s.title.strip(), f"слайд {s.number}: пустой заголовок" assert s.bullets, f"слайд {s.number}: ни одного тезиса (D4: 3–6 тезисов)" def test_presentation_covers_mandatory_narrative_bits(): low = _read("presentation.md").lower() for bit in ("проблем", "решени", "конвейер", "сценари", "тираж", "статус"): assert bit in low, f"нормативный бит нарратива {bit!r} отсутствует (FR-4 / AC-7)" def test_presentation_covers_lite_and_plane_usage_bits(): """ORCH-105 (FR-5 / AC-4): новый обязательный контент презентации зафиксирован анти-дрейфом — бесследное удаление слайда Lite-установки или слайдов «как пользоваться орком через Plane» рвёт CI. Матч по lower-case подстрокам (как `test_presentation_covers_mandatory_narrative_bits`); маркеры — семантические корни и дословные имена статусов/лейблов (анти- переобучение к формулировкам), все вне FORBIDDEN/секрет-эвристики.""" low = _read("presentation.md").lower() # Слайд Lite-установки: корень `lite` + маркер развёртывания (FR-1 / AC-1). assert "lite" in low, "слайд про Lite-установку отсутствует (FR-1 / AC-1)" assert any(m in low for m in ("установк", "разверн")), ( "слайд Lite не несёт маркера установки/развёртывания (FR-1 / AC-1)" ) # Слайды использования через Plane: корень `plane` + операторские маркеры. # Точка входа, оба человеческих гейта и отмена — дословными именами статусов # (FR-2 / AC-2; имена сверены с tech-pipeline.md / tech-integrations.md). assert "plane" in low, "слайды использования через Plane отсутствуют (FR-2 / AC-2)" assert "to analyse" in low, ( "слайды Plane-usage не называют точку входа «To Analyse» (FR-2 / AC-2)" ) assert "approved" in low, ( "слайды Plane-usage не называют человеческий гейт «Approved» (FR-2 / AC-2)" ) assert "confirm deploy" in low, ( "слайды Plane-usage не называют гейт прод-выкладки «Confirm Deploy» (FR-2 / AC-2)" ) assert "stop" in low, ( "слайды Plane-usage не называют отмену задачи «STOP» (FR-2 / AC-2)" ) def test_presentation_carries_reproducible_build_procedure(): text = _read("presentation.md") assert "build_presentation.py" in text, ( "процедура сборки .pptx не ссылается на скрипт (AC-7)" ) assert "Проверка" in text, ( "процедура сборки не несёт явных маркеров «Проверка:» (канон LITE_SETUP)" ) # --------------------------------------------------------------------------- # TC-11 (план TC-11): NFR-2 машинно — pptx не в прод-образе. # --------------------------------------------------------------------------- def test_no_pptx_dependency_in_prod_image(): files = sorted(REPO_ROOT.glob("requirements*")) + [REPO_ROOT / "Dockerfile"] assert files, "requirements*/Dockerfile не найдены — скан пуст" offenders = [ p.name for p in files if "pptx" in p.read_text(encoding="utf-8").lower() ] assert not offenders, ( f"зависимость генерации презентации попала в прод-образ (NFR-2): {offenders}" ) # --------------------------------------------------------------------------- # TC-12 (план TC-12): указатели репо — README / CLAUDE.md / CHANGELOG (AC-9/AC-11). # --------------------------------------------------------------------------- def test_repo_readme_links_overview(): assert "docs/overview/" in (REPO_ROOT / "README.md").read_text(encoding="utf-8"), ( "README.md не ссылается на витрину docs/overview/ (AC-11)" ) def test_claude_md_carries_overview_pointer_and_normative(): text = (REPO_ROOT / "CLAUDE.md").read_text(encoding="utf-8") assert "docs/overview/" in text, "CLAUDE.md не несёт указатель на витрину (AC-9)" def test_changelog_has_orch_011_entry(): assert "ORCH-011" in (REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8"), ( "CHANGELOG.md не несёт docs:-записи по ORCH-011 (AC-11)" )