Files
orchestrator/tests/test_system_docs.py
claude-bot 6d798c01ef docs(overview): витрина системы docs/overview/ — бизнес+тех, 3 аудитории, презентация (ORCH-011)
Единая точка входа в документацию платформы (ADR-001 D1–D9):
- docs/overview/ — 10 файлов: индекс (маршруты «Я заказчик / Я менеджер /
  Я разработчик» + норматив «изменил функциональность → обнови витрину в том же
  PR»), business.md (без жаргона, 6 сценариев), 7 тех-блоков (link-first),
  presentation.md (16 слайдов + процедура сборки «команда + Проверка:»).
- scripts/build_presentation.py — генератор .pptx в тёмном дизайне (python-pptx;
  чистый stdlib-парсер parse_slides + ленивый import pptx; бинарь не коммитится,
  build/ в .gitignore; зависимость НЕ в прод-образе — машинный гард TC-09).
- tests/test_system_docs.py — структурный анти-дрейф: derive-сверки стадий/
  гейтов/агентов импортом STAGE_TRANSITIONS/QG_CHECKS/glob промптов/config,
  валидность ссылок, FORBIDDEN-скан + секрет-эвристика, слайды каноническим
  парсером, NFR-2, указатели.
- reviewer.md — ось обзорных доков ORCH-079 расширена на витрину (D7; канон 52d
  байт-в-байт, только текст внутри секций) + анти-регресс ассерт в
  test_agent_prompts_canon.py.
- Указатели: README.md, CLAUDE.md (правила №2/№6, «Структура»),
  PRODUCT_VISION.md (врезка-ссылка), CHANGELOG.md.

Рантайм байт-в-байт: src/**, docker-compose.yml, Dockerfile, requirements* —
ноль изменений (docs+tests+dev-скрипт, паттерн ORCH-102/103). pytest: 1873 passed.

Refs: ORCH-011

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 09:36:40 +03:00

445 lines
22 KiB
Python
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.
"""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"(?<![\w-]){re.escape(stage)}(?![\w-])")
def _first_stage_pos(text: str, stage: str) -> 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: ориентир 1418)"
)
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: 36 тезисов)"
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_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)"
)