diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 696a4fd9..ca082227 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,14 @@ jobs: - name: Lint (ruff) run: ruff check src/ tests/ + - name: Docs references (docs = code) + # Механическая форма AGENTS §8: изменённый .md обязан добавить проверяемую + # ссылку на код (`src/...py`, `path::symbol`, `install.py --flag`, env-имя). + # Проверяется дифф PR, а не весь файл. Локальный аналог: --staged. + run: > + python scripts/check_docs_refs.py + --base "${{ github.event.pull_request.base.sha || 'origin/main' }}" + - name: Client snippets cold start (G5) # Каждый сниппет из `install.py --print-config` запускается на чистом # раннере: initialize + tools/list. Покрывает все 5 клиентов, включая diff --git a/AGENTS.md b/AGENTS.md index c4fdf598..5ab5bb7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -321,7 +321,8 @@ impact-guard). пропускается автоматически. 7. CI проверен не «на словах»: `gh run list --branch <ветка>` и `gh run view --log-failed`. Джобы: `test` (ubuntu + windows, Python 3.14), - `clean-state`, `manifest-parity`, `docker-build`, `transport-equiv-e2e`. + `clean-state`, `manifest-parity`, `docker-build`, `transport-equiv-e2e`; шаг + `Docs references` внутри `test` проверяет дифф документации (§8). 8. Для изменений в индексе/провайдерах — живой smoke: `python scripts/smoke_e2e.py --project .` (реальные сервисы, без моков). 9. `bash scripts/verify_clean_state.sh` (clone + venv + install + полный прогон) → @@ -345,6 +346,19 @@ impact-guard). документации в PR ссылается на факт кода (`файл:символ`), который она отражает. Правка «только цифра» без ссылки отклоняется до коммита (чек-лист `.github/pull_request_template.md`). +- **Guard изменения docs:** `scripts/check_docs_refs.py --base ` (шаг CI + `Docs references`; локально вручную `--staged`) требует, чтобы изменённый `.md` + добавил **проверяемую** ссылку: существующий путь внутри репо (`.githooks/…` тоже), + `path::symbol` — символ **объявлен** в файле (`def`/`class`/присваивание), а не + упомянут; `path:NNN` — строка в пределах файла; `install.py --flag` — флаг известен; + имя переменной, которое код **читает** из окружения (`os.getenv`/`process.env`). + Косметика допускается только в файле, который уже ссылался на код и не вносит новых + утверждений (новая цифра/путь/флаг; перенесённая строка с уже существующей цифрой — + не новое утверждение). Тесты: `tests/test_docs_refs_gate.py` (35), включая матрицу + обхода: выдуманный путь/символ/флаг/переменная, префикс реального имени, `::return`, + traversal, junction, C-quoted не-ASCII путь, лимиты 200 файлов/20 000 строк. + Гейт доказывает существование факта, **не** его релевантность утверждению — + эту границу держит ревьюер. - **Guard дрейфа флагов/клиентов:** `scripts/check_docs_vs_installer.py` — каждый `--flag` и `--client X` из `README.md`/`docs/*/{INSTALL,CLIENTS,DATA_MAP}.md` обязан существовать в `install.py`/`adapters/clients/snippets.py` (`tests/ diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index eea0dfd2..7a0c7954 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -1941,3 +1941,17 @@ BREACH: MSC-P-19 | date: 2026-10-03 | rule: §12 | guard_at_time: no | detected_ **3. Код установщика в диффах есть.** `gh pr diff 120 | grep auto_yes` → удалённая строка `range(0 if auto_yes …)`, добавленная `range(1 if auto_yes …)` и тест `test_auto_yes_runs_each_step_once`. `gh pr diff 121 | grep "def _"` → `models_enabled`, `_doctor_report`, `_doctor_cli`, `_clean_targets`, `_clean_cli` (имена не `cmd_*`; поиск по `cmd_clean|cmd_doctor` пуст по этой причине). **4. Гипотеза «clean-state до фикса валидировал пустую установку» — опровергнута.** `scripts/verify_clean_state.sh` вообще не вызывает `install.py`: он сам создаёт venv и делает `pip install -e .`; `grep "install.py --yes"` по `.github/` и `scripts/` пуст. Значит баг `--yes` с v3.0 не влиял ни на один прогон CI — ровно поэтому его никто и не поймал; поймал только ручной прогон по README. **Урок:** «CI зелёный» и «инструкция работает» — разные утверждения. Пока установщик не запускали как пользователь (по README, в пустом каталоге), его неинтерактивная ветка была мёртвой, а все гейты оставались зелёными. + +## [2026-11-14] docs = code стало механическим: гейт на ИЗМЕНЕНИЕ, а не на файл +**Проблема:** правило «каждая правка docs ссылается на факт кода» держалось на ревью. Два существующих guard'а смотрели файл целиком (флаги — `check_docs_vs_installer.py`, переменные — `check_docs_vs_config.py`) и не видели, что именно изменилось в PR. +**Сделано:** `scripts/check_docs_refs.py` (режимы `--base` / `--staged` / `--diff-file`). Изменённый `.md` обязан добавить **проверяемую** ссылку: существующий путь внутри репо, `path::symbol` (символ **объявлен**, а не упомянут), `path:NNN` (строка в пределах файла), `install.py --flag` (флаг известен), имя переменной, которую код **читает** из окружения. Косметика проходит только если файл уже ссылался на код и добавленные строки не вносят новых утверждений (перенесённая строка с уже существующей цифрой — не новое утверждение). Исключения: `docs/archive|blog|research/**`, `experiments/**`, реестры по имени файла (`docs/*/CHANGELOG.md` тоже). Лимиты: >200 файлов или >20 000 строк → `exit 3`. +**CI:** шаг `Docs references` в job `test`, база — `github.event.pull_request.base.sha`. +**Независимое ревью (свежий контекст) нашло 4 блокера — все закрыты:** +1. env подтверждался подстрокой (`MSCODEBASE_DOCS` ⊂ `MSCODEBASE_DOCS_MARGIN` → проходил) → теперь множество имён, реально читаемых через `os.getenv`/`process.env`, сравнение по целому имени; +2. на Windows-консоли гейт падал трейсбеком на `✗` (cp1252) и негативные тесты «проходили» из-за крэша → `stdout.reconfigure(utf-8, errors=replace)` + ASCII-маркеры; прогон с `PYTHONIOENCODING=cp1252` → 35 passed; +3. git C-quoted пути (не-ASCII/пробел) не распознавались → молчаливый `rc=0`; теперь `unquote_git_path` + тест; +4. перенос строки с уже существующей цифрой блокировался → «новое утверждение» = текст, которого не было в файле до правки. +Плюс major'ы: `experiments/**` и `docs/*/CHANGELOG.md` добавлены в исключения; env больше не привязан к трём префиксам (`ONNX_FIXED_PADDING` теперь валиден); неизвестный флаг больше не отбрасывает валидную ссылку в той же строке. +**Доказательства:** 35 тестов; 7 мутаций (символ/флаг/containment/файловый cap/строковый cap/claim-клауза/env-точность) — **каждая краснеет**; реплей 36 линейных коммитов `main` с `*.md` → **0 заблокированных** (до правок было 10/36); G5+G6+G7 = 45 markdown-файлов, 0 без ссылки. +**Принятые риски (записаны в правиле):** A1 «реальный, но нерелевантный символ», A2 ссылка только в fenced-блоке, A3 документы не в `.md`. Гейт доказывает существование факта, не его релевантность. +**Урок:** guard на изменение точнее, чем guard на файл, но он ловит только «ссылки нет» и «ссылка выдумана»; «ссылка не по делу» остаётся человеку — и это надо писать в самом правиле, иначе её начнут считать доказанной. diff --git a/scripts/check_docs_refs.py b/scripts/check_docs_refs.py new file mode 100644 index 00000000..4cce0d6f --- /dev/null +++ b/scripts/check_docs_refs.py @@ -0,0 +1,404 @@ +#!/usr/bin/env python +"""Mechanical form of «документация = код»: a changed .md must cite a real fact. + +The rule in `AGENTS.md §8` says every documentation change points at the code it +describes. Two guards check *whole files*: installer flags +(`check_docs_vs_installer.py`) and environment variables (`check_docs_vs_config.py`). +Neither looks at the *change*, so "правка только цифры без ссылки" depended on a +human reviewer. This gate reads the diff and requires that a changed markdown +file adds at least one **verifiable** code reference: + + src/core/search/engine.py path exists inside the repo + src/core/search/engine.py::Searcher symbol is DECLARED in that file + src/core/search/engine.py:288 line number is inside the file + install.py::_clean_cli installer function exists + install.py --with-models flag is known to the installer + MSCODEBASE_DOCS_MARGIN name is read from the environment in code + +Verification, not pattern matching: a fabricated path, a real path with an +invented or merely-mentioned symbol, a line past the end of the file, or a name +that is only a prefix of a real setting all fail. + +Cosmetic edits are allowed when the file already cited code and the added lines +add no *new* claim: a line carrying digits/paths/flags counts as new unless the +exact same text already existed in the file before the change (moved lines are +not new claims). + +Exit codes: 0 clean / not applicable, 1 violations, 3 internal limit exceeded or +unreadable input. Empty markdown input is reported explicitly, never silently. + +Usage: + python scripts/check_docs_refs.py --base origin/main + python scripts/check_docs_refs.py --staged + python scripts/check_docs_refs.py --diff-file change.diff +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path + +MAX_FILES = 200 +MAX_ADDED_LINES = 20_000 + +# Registries, historical material and experiment artefacts are logs of runs, +# not descriptions of today (AGENTS §0.6: experiments are not indexed either). +EXEMPT_PREFIXES = ( + "docs/archive/", + "docs/blog/", + "docs/research/", + "experiments/", +) +# Matched against the file NAME, so `docs/{en,ru,zh}/CHANGELOG.md` is covered too. +EXEMPT_NAMES = { + "AGENT_DIARY.md", + "KNOWN_ISSUES.md", + "EXPERIMENTS_LOG.md", + "WISDOM.md", + "ISSUE.md", + "CHANGELOG.md", + "CODE_OF_CONDUCT.md", + "LICENSE.md", +} + +PATH_RE = re.compile( + r"(?P(?:src|scripts|tests|adapters|tools)/[A-Za-z0-9_./-]+\.(?:py|sh|toml|json|yml|yaml))" + r"(?:::(?P[A-Za-z_][A-Za-z0-9_]*)|:(?P\d+))?" +) +INSTALLER_RE = re.compile(r"install\.py(?:::(?P[A-Za-z_][A-Za-z0-9_]*)|:(?P\d+))?") +HOOK_RE = re.compile(r"(?P\.githooks/[A-Za-z0-9_-]+|\.opencode/plugin/[A-Za-z0-9_.-]+)") +FLAG_RE = re.compile(r"install\.py\s+(--[a-z][a-z0-9-]*)") +VAR_RE = re.compile(r"\b([A-Z][A-Z0-9_]{2,})\b") +CLAIM_RE = re.compile( + r"(\d|--[a-z][a-z0-9-]*|(?:src|scripts|tests|adapters|tools)/|install\.py|" + r"MSCODEBASE_|MSCB_|\.githooks/|\.opencode/)" +) + +# A setting is a name the code actually reads from the environment. +ENV_READ_RE = re.compile( + r"""(?:os\.getenv|os\.environ\.get|os\.environ\[|getenv|process\.env)(?:\(|\[|\.)\s*['"]?([A-Z][A-Z0-9_]{2,})""" +) + +CORPUS_GLOBS = ( + "src/**/*.py", + "scripts/**/*.py", + "adapters/**/*.py", + "install.py", + ".opencode/**/*.ts", + ".githooks/*", +) + + +@dataclass +class FileChange: + path: str + added: list[tuple[int, str]] = field(default_factory=list) + + +def _git(repo: Path, *args: str) -> str: + proc = subprocess.run( + ["git", *args], cwd=str(repo), capture_output=True, text=True, + encoding="utf-8", errors="replace", + ) + if proc.returncode != 0: + raise RuntimeError(f"git {' '.join(args)} failed: {proc.stderr.strip()[:200]}") + return proc.stdout + + +def load_diff(repo: Path, *, staged: bool, base: str | None, diff_file: Path | None) -> str: + if diff_file is not None: + # Not every tool writes UTF-8 (PowerShell `>` writes UTF-16): say so + # instead of raising a traceback (2026-11-14, found by feeding a real diff). + try: + return diff_file.read_text(encoding="utf-8", errors="strict") + except UnicodeDecodeError as e: + raise RuntimeError(f"{diff_file} is not UTF-8: {e}") from e + args = ["diff", "--unified=0", "--no-color", "--", "*.md"] + if staged: + args.insert(1, "--cached") + return _git(repo, *args) + assert base, "either --staged, --base or --diff-file is required" + merge_base = _git(repo, "merge-base", base, "HEAD").strip() or base + return _git(repo, "diff", "--unified=0", "--no-color", merge_base, "HEAD", "--", "*.md") + + +def unquote_git_path(raw: str) -> str: + """Decode git's C-quoted path (`"b/docs/ru/\\320\\244..."`).""" + raw = raw.strip() + if len(raw) < 2 or not (raw.startswith('"') and raw.endswith('"')): + return raw + body = raw[1:-1] + out = bytearray() + i = 0 + escapes = {"n": b"\n", "t": b"\t", "r": b"\r", "a": b"\a", + "b": b"\b", "f": b"\f", "v": b"\v", '"': b'"', "\\": b"\\"} + while i < len(body): + ch = body[i] + if ch == "\\" and i + 1 < len(body): + oct_digits = body[i + 1: i + 4] + if len(oct_digits) == 3 and all(c in "01234567" for c in oct_digits): + out.append(int(oct_digits, 8)) + i += 4 + continue + nxt = body[i + 1] + out += escapes.get(nxt, nxt.encode("utf-8", "replace")) + i += 2 + continue + out += ch.encode("utf-8") + i += 1 + return out.decode("utf-8", "replace") + + +def parse_diff(text: str) -> tuple[list[FileChange], int]: + changes: list[FileChange] = [] + current: FileChange | None = None + new_line = 0 + headers = 0 + for raw in text.splitlines(): + if raw.startswith("+++ "): + headers += 1 + target = raw[4:].strip() + if target == "/dev/null": + current = None + continue + target = unquote_git_path(target) + if target.startswith("b/"): + target = target[2:] + current = FileChange(path=target) + changes.append(current) + continue + if raw.startswith("@@"): + m = re.search(r"\+(\d+)", raw) + new_line = int(m.group(1)) if m else 0 + continue + if current is None: + continue + if raw.startswith("+") and not raw.startswith("+++"): + current.added.append((new_line, raw[1:])) + new_line += 1 + return [c for c in changes if c.added], headers + + +def is_exempt(path: str) -> bool: + return Path(path).name in EXEMPT_NAMES or path.startswith(EXEMPT_PREFIXES) + + +def corpus(repo: Path) -> str: + chunks: list[str] = [] + for pattern in CORPUS_GLOBS: + for p in repo.glob(pattern): + if p.is_file(): + try: + chunks.append(p.read_text(encoding="utf-8", errors="replace")) + except OSError: + continue + return "\n".join(chunks) + + +def env_names(text: str) -> set[str]: + """Names the code actually reads from the environment (not any uppercase word).""" + return set(ENV_READ_RE.findall(text)) + + +def symbol_declared(body: str, symbol: str) -> bool: + """A definition, not a mention: comments and docstrings must not count.""" + s = re.escape(symbol) + patterns = ( + rf"(?m)^\s*(?:async\s+)?def\s+{s}\b", + rf"(?m)^\s*class\s+{s}\b", + rf"(?m)^\s*{s}\s*(?::[^=\n]+)?=", + rf"(?m)^\s*{s}\s*:", + rf"""(?m)^\s*['"]{s}['"]\s*:""", + ) + return any(re.search(p, body) for p in patterns) + + +def _inside(repo: Path, candidate: Path) -> bool: + try: + resolved = candidate.resolve() + except OSError: + return False + return resolved == repo.resolve() or repo.resolve() in resolved.parents + + +def _file_text(path: Path) -> str | None: + try: + return path.read_text(encoding="utf-8", errors="replace") + except OSError: + return None + + +def verify_reference(line: str, repo: Path, envs: set[str], flags: set[str]) -> str | None: + """Return a description of the first verified reference in the line.""" + for m in FLAG_RE.finditer(line): + if m.group(1) in flags: + return f"flag {m.group(1)}" + + for m in HOOK_RE.finditer(line): + target = repo / m.group("path") + if _inside(repo, target) and target.exists(): + return f"path {m.group('path')}" + + for m in PATH_RE.finditer(line): + rel = m.group("path") + target = repo / rel + if not _inside(repo, target) or not target.exists(): + continue + symbol, lineno = m.group("symbol"), m.group("line") + body = _file_text(target) + if body is None: + continue + if symbol: + if symbol_declared(body, symbol): + return f"{rel}::{symbol}" + continue + if lineno: + if 1 <= int(lineno) <= len(body.splitlines()): + return f"{rel}:{lineno}" + continue + return f"path {rel}" + + for m in INSTALLER_RE.finditer(line): + target = repo / "install.py" + body = _file_text(target) + if body is None: + continue + symbol, lineno = m.group("symbol"), m.group("line") + if symbol: + if symbol_declared(body, symbol): + return f"install.py::{symbol}" + continue + if lineno: + if 1 <= int(lineno) <= len(body.splitlines()): + return f"install.py:{lineno}" + continue + # A bare `install.py` mention is not a fact about the code. + + for m in VAR_RE.finditer(line): + if m.group(1) in envs: + return f"env {m.group(1)}" + return None + + +def _head_lines(repo: Path, path: str) -> set[str] | None: + try: + body = _git(repo, "show", f"HEAD:{path}") + except RuntimeError: + return None + return {ln.strip() for ln in body.splitlines() if ln.strip()} + + +def _head_cites_code(repo: Path, path: str, envs: set[str], flags: set[str]) -> bool: + try: + body = _git(repo, "show", f"HEAD:{path}") + except RuntimeError: + return False + return any(verify_reference(ln, repo, envs, flags) for ln in body.splitlines()) + + +def installer_flags(repo: Path) -> set[str]: + body = _file_text(repo / "install.py") + if body is None: + return set() + return set(re.findall(r'"(--[a-z][a-z0-9-]*)"', body)) + + +def check(repo: Path, diff_text: str) -> tuple[list[str], list[str], int]: + files, headers = parse_diff(diff_text) + if len(files) > MAX_FILES: + return [f"diff touches {len(files)} markdown files (limit {MAX_FILES}) — refusing to pass"], [], len(files) + total_added = sum(len(f.added) for f in files) + if total_added > MAX_ADDED_LINES: + return [f"diff adds {total_added} markdown lines (limit {MAX_ADDED_LINES}) — refusing to pass"], [], total_added + if not files: + return [], [f"no added markdown lines ({headers} file header(s): deletions/renames only)"], 0 + + envs = env_names(corpus(repo)) + flags = installer_flags(repo) + problems: list[str] = [] + notes: list[str] = [] + + for change in files: + if is_exempt(change.path): + notes.append(f"exempt: {change.path}") + continue + verified = None + for _lineno, text in change.added: + verified = verify_reference(text, repo, envs, flags) + if verified: + break + if verified: + notes.append(f"ok: {change.path} -> {verified}") + continue + + old_lines = _head_lines(repo, change.path) or set() + new_claims = [ + text for _n, text in change.added + if CLAIM_RE.search(text) and text.strip() not in old_lines + ] + if not new_claims and _head_cites_code(repo, change.path, envs, flags): + notes.append(f"ok: {change.path} (cosmetic edit, file already cites code)") + continue + first = change.added[0][0] + reason = "new claim without a reference" if new_claims else "file never cited code" + problems.append( + f"{change.path}:{first}: no verifiable code reference ({reason}; add " + f"`src/…py`, `path::symbol`, `install.py --flag` or a setting read by the code)" + ) + return problems, notes, len(files) + + +def main() -> int: + if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"): + try: # Windows console code pages crash on non-ASCII output (cp1252, 2026-11-14) + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + + ap = argparse.ArgumentParser() + ap.add_argument("--repo", default=str(Path(__file__).resolve().parents[1])) + ap.add_argument("--staged", action="store_true") + ap.add_argument("--base", default=None) + ap.add_argument("--diff-file", default=None) + ap.add_argument("--json", action="store_true") + args = ap.parse_args() + + repo = Path(args.repo).resolve() + try: + diff_text = load_diff( + repo, staged=args.staged, base=args.base, + diff_file=Path(args.diff_file) if args.diff_file else None, + ) + except (RuntimeError, OSError, UnicodeDecodeError) as e: + print(f"docs-refs: cannot read the diff: {e}", file=sys.stderr) + return 3 + + problems, notes, files_seen = check(repo, diff_text) + hard_limit = any("refusing to pass" in p for p in problems) + + if args.json: + print(json.dumps({"files": files_seen, "problems": problems, "notes": notes}, + indent=2, ensure_ascii=False)) + return 3 if hard_limit else (0 if not problems else 1) + + for note in notes: + print(f" - {note}") + for problem in problems: + print(f" FAIL {problem}") + if files_seen == 0: + print("docs-refs: no markdown additions in this diff — nothing to prove") + else: + print(f"docs-refs: {files_seen} markdown file(s) changed, {len(problems)} without a code reference") + if hard_limit: + return 3 + return 0 if not problems else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_docs_refs_gate.py b/tests/test_docs_refs_gate.py new file mode 100644 index 00000000..faf83f37 --- /dev/null +++ b/tests/test_docs_refs_gate.py @@ -0,0 +1,400 @@ +"""Tests for the docs->code reference gate (G7, mechanical form). + +Every test feeds a crafted unified diff (`--diff-file`) or a throwaway git repo, +so the gate is exercised exactly as CI runs it without depending on this +repository's history. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] +SCRIPT = REPO / "scripts" / "check_docs_refs.py" + + +def _diff(path: str, added: list[str], start: int = 1) -> str: + body = "".join(f"+{line}\n" for line in added) + return ( + f"diff --git a/{path} b/{path}\n" + f"--- a/{path}\n" + f"+++ b/{path}\n" + f"@@ -0,0 +{start},{len(added)} @@\n" + f"{body}" + ) + + +def _run(tmp: Path, diff_text: str, *extra: str) -> tuple[int, str]: + diff_file = tmp / "change.diff" + diff_file.write_text(diff_text, encoding="utf-8") + proc = subprocess.run( + [sys.executable, str(SCRIPT), "--repo", str(tmp), "--diff-file", str(diff_file), *extra], + capture_output=True, text=True, encoding="utf-8", errors="replace", + ) + return proc.returncode, proc.stdout + proc.stderr + + +def _make_repo(tmp: Path) -> None: + (tmp / "src" / "core" / "search").mkdir(parents=True) + (tmp / "src" / "core" / "search" / "engine.py").write_text( + "class Searcher:\n" + " # old Searcher notes\n" + " def search(self):\n" + " return 1\n", encoding="utf-8" + ) + (tmp / "src" / "core" / "embedder.py").write_text( + "import os\n\n\ndef width():\n" + ' return int(os.getenv("ONNX_FIXED_PADDING", "0") or 0)\n', encoding="utf-8" + ) + (tmp / "src" / "core" / "settings.py").write_text( + "import os\n\n" + 'DOCS_MARGIN = float(os.getenv("MSCODEBASE_DOCS_MARGIN", "4.0"))\n', encoding="utf-8" + ) + (tmp / "install.py").write_text( + 'def _clean_cli(argv):\n return 0\n\n\nFLAG = "--clean"\nFLAG2 = "--with-models"\n', + encoding="utf-8", + ) + (tmp / ".env.example").write_text("MSCODEBASE_DOCS_MARGIN=4.0\n", encoding="utf-8") + (tmp / "docs" / "en").mkdir(parents=True) + (tmp / "docs" / "archive").mkdir(parents=True) + (tmp / "experiments" / "e1").mkdir(parents=True) + + +def _git_repo(tmp: Path, files: dict[str, str]) -> Path: + repo = tmp / "g" + repo.mkdir() + env = { + **os.environ, + "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@e", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@e", + } + + def git(*args: str) -> None: + subprocess.run(["git", *args], cwd=str(repo), check=True, capture_output=True, env=env) + + for rel, body in files.items(): + target = repo / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(body, encoding="utf-8") + git("init", "-q") + git("add", ".") + git("commit", "-q", "-m", "init") + return repo + + +def _run_in(repo: Path, diff_text: str) -> tuple[int, str]: + diff_file = repo / "change.diff" + diff_file.write_text(diff_text, encoding="utf-8") + proc = subprocess.run( + [sys.executable, str(SCRIPT), "--repo", str(repo), "--diff-file", str(diff_file)], + capture_output=True, text=True, encoding="utf-8", errors="replace", + ) + return proc.returncode, proc.stdout + proc.stderr + + +# ─── basic acceptance / rejection ──────────────────────────────────────── + + +def test_no_markdown_changes_is_reported_not_silent(tmp_path): + _make_repo(tmp_path) + code, out = _run(tmp_path, "") + assert code == 0 + assert "no markdown additions" in out + + +def test_real_path_is_accepted(tmp_path): + _make_repo(tmp_path) + code, out = _run(tmp_path, _diff("docs/en/x.md", ["See `src/core/search/engine.py`."])) + assert code == 0, out + assert "ok: docs/en/x.md -> path src/core/search/engine.py" in out + + +def test_declared_symbol_is_accepted(tmp_path): + _make_repo(tmp_path) + code, out = _run(tmp_path, _diff("docs/en/x.md", ["Entry: `src/core/search/engine.py::Searcher`."])) + assert code == 0, out + assert "::Searcher" in out + + +def test_symbol_that_is_only_mentioned_is_rejected(tmp_path): + """`Searcher` also appears in a comment; only a declaration counts.""" + _make_repo(tmp_path) + code, out = _run(tmp_path, _diff("docs/en/x.md", ["See `src/core/search/engine.py::notes`."])) + assert code == 1, out + + +def test_keyword_symbol_is_rejected(tmp_path): + _make_repo(tmp_path) + code, out = _run(tmp_path, _diff("docs/en/x.md", ["See `src/core/search/engine.py::return`."])) + assert code == 1, out + + +def test_real_line_number_is_accepted(tmp_path): + _make_repo(tmp_path) + code, out = _run(tmp_path, _diff("docs/en/x.md", ["Limit: `src/core/search/engine.py:3`."])) + assert code == 0, out + + +def test_line_past_end_is_rejected(tmp_path): + _make_repo(tmp_path) + code, out = _run(tmp_path, _diff("docs/en/x.md", ["See `src/core/search/engine.py:999`."])) + assert code == 1, out + + +def test_fabricated_path_is_rejected(tmp_path): + _make_repo(tmp_path) + code, out = _run(tmp_path, _diff("docs/en/x.md", ["See `src/core/nope.py`."])) + assert code == 1, out + assert "no verifiable code reference" in out + + +def test_fabricated_symbol_is_rejected(tmp_path): + _make_repo(tmp_path) + code, out = _run(tmp_path, _diff("docs/en/x.md", ["See `src/core/search/engine.py::make_coffee`."])) + assert code == 1, out + + +# ─── installer flags ───────────────────────────────────────────────────── + + +def test_unknown_installer_flag_is_rejected(tmp_path): + _make_repo(tmp_path) + code, out = _run(tmp_path, _diff("docs/en/x.md", ["Run `install.py --uninstall`."])) + assert code == 1, out + + +def test_known_installer_flag_is_accepted(tmp_path): + _make_repo(tmp_path) + code, out = _run(tmp_path, _diff("docs/en/x.md", ["Run `install.py --clean`."])) + assert code == 0, out + + +def test_unknown_flag_does_not_hide_a_valid_reference(tmp_path): + """An early unknown flag must not abort the rest of the line.""" + _make_repo(tmp_path) + code, out = _run( + tmp_path, + _diff("docs/en/x.md", ["Run `install.py --uninstall` or see `src/core/search/engine.py`."]), + ) + assert code == 0, out + assert "path src/core/search/engine.py" in out + + +# ─── environment settings ──────────────────────────────────────────────── + + +def test_invented_env_name_is_rejected(tmp_path): + _make_repo(tmp_path) + code, out = _run(tmp_path, _diff("docs/en/x.md", ["Set `MSCODEBASE_FAKE_FLAG=true`."])) + assert code == 1, out + + +def test_env_prefix_of_a_real_name_is_rejected(tmp_path): + """Substring match would accept this: `MSCODEBASE_DOCS` is a prefix.""" + _make_repo(tmp_path) + code, out = _run(tmp_path, _diff("docs/en/x.md", ["Set `MSCODEBASE_DOCS=1`."])) + assert code == 1, out + + +def test_real_env_name_is_accepted(tmp_path): + _make_repo(tmp_path) + code, out = _run(tmp_path, _diff("docs/en/x.md", ["Tune `MSCODEBASE_DOCS_MARGIN`."])) + assert code == 0, out + + +def test_setting_without_the_project_prefix_is_accepted(tmp_path): + """`ONNX_FIXED_PADDING` is read by the code but has no MSCODEBASE_ prefix.""" + _make_repo(tmp_path) + code, out = _run(tmp_path, _diff("docs/en/x.md", ["Set `ONNX_FIXED_PADDING=512`."])) + assert code == 0, out + + +# ─── exemptions and paths ─────────────────────────────────────────────── + + +def test_exempt_archive_passes_without_reference(tmp_path): + _make_repo(tmp_path) + code, out = _run(tmp_path, _diff("docs/archive/old.md", ["Historical note, no reference."])) + assert code == 0, out + assert "exempt" in out + + +def test_experiments_are_exempt(tmp_path): + _make_repo(tmp_path) + code, out = _run(tmp_path, _diff("experiments/e1/REPORT.md", ["Run log, no reference."])) + assert code == 0, out + + +def test_translated_changelog_is_exempt(tmp_path): + _make_repo(tmp_path) + code, out = _run(tmp_path, _diff("docs/ru/CHANGELOG.md", ["## 3.5.1 — fix"])) + assert code == 0, out + + +def test_path_traversal_does_not_count(tmp_path): + _make_repo(tmp_path) + outside = tmp_path.parent / "outside_secret.py" + outside.write_text("SECRET = 1\n", encoding="utf-8") + code, out = _run(tmp_path, _diff("docs/en/x.md", ["See `src/../../outside_secret.py`."])) + assert code == 1, out + + +def test_reference_to_a_file_absent_from_the_tree_is_rejected(tmp_path): + _make_repo(tmp_path) + code, out = _run(tmp_path, _diff("docs/en/x.md", ["Moved to `src/core/gone.py`."])) + assert code == 1, out + + +def test_homoglyph_path_is_rejected(tmp_path): + """Attack: look-alike Cyrillic letter inside a path.""" + _make_repo(tmp_path) + code, out = _run(tmp_path, _diff("docs/en/x.md", ["See `src/core/search/enginе.py`."])) + assert code == 1, out + + +def test_quoted_non_ascii_path_in_diff_is_parsed(tmp_path): + """git C-quotes non-ASCII paths; the file must still be judged.""" + _make_repo(tmp_path) + quoted = '"b/docs/en/\\320\\244.md"' + diff = ( + "diff --git a/docs/en/x.md b/docs/en/x.md\n" + "--- a/docs/en/x.md\n" + f"+++ {quoted}\n" + "@@ -0,0 +1,1 @@\n" + "+Новый текст без ссылки.\n" + ) + code, out = _run(tmp_path, diff) + assert code == 1, out + assert "no verifiable code reference" in out + + +def test_junction_to_outside_the_repo_is_rejected(tmp_path): + if sys.platform != "win32": + pytest.skip("junction is a Windows primitive") + _make_repo(tmp_path) + outside = tmp_path.parent / "outside_dir" + outside.mkdir(exist_ok=True) + (outside / "secret.py").write_text("SECRET = 1\n", encoding="utf-8") + subprocess.run(["cmd", "/c", "mklink", "/J", str(tmp_path / "src" / "linked"), str(outside)], + capture_output=True, text=True) + code, out = _run(tmp_path, _diff("docs/en/x.md", ["See `src/linked/secret.py`."])) + assert code == 1, out + + +# ─── cosmetic edits ───────────────────────────────────────────────────── + + +def test_cosmetic_edit_to_a_file_that_already_cites_code_passes(tmp_path): + repo = _git_repo( + tmp_path, + {"docs/en/a.md": "Entry: `src/main.py`.\n", "src/main.py": "def main():\n return 0\n"}, + ) + code, out = _run_in(repo, _diff("docs/en/a.md", ["Fixed a typo in the prose."])) + assert code == 0, out + assert "cosmetic" in out + + +def test_moved_line_with_existing_numbers_is_not_a_new_claim(tmp_path): + old = "Counts: missing_widget=0, implemented=5\n" + repo = _git_repo( + tmp_path, + {"docs/en/a.md": "Entry: `src/main.py`.\n\n" + old, + "src/main.py": "def main():\n return 0\n"}, + ) + code, out = _run_in(repo, _diff("docs/en/a.md", ["Counts: missing_widget=0, implemented=5"])) + assert code == 0, out + + +def test_new_number_in_a_citing_file_still_needs_a_reference(tmp_path): + repo = _git_repo( + tmp_path, + {"docs/en/a.md": "Entry: `src/main.py`.\n", "src/main.py": "def main():\n return 0\n"}, + ) + code, out = _run_in(repo, _diff("docs/en/a.md", ["Now 2507 tests pass."])) + assert code == 1, out + assert "new claim" in out + + +def test_prose_in_a_file_that_never_cited_code_is_rejected(tmp_path): + repo = _git_repo(tmp_path, {"docs/en/a.md": "Plain prose.\n"}) + code, out = _run_in(repo, _diff("docs/en/a.md", ["Another plain sentence."])) + assert code == 1, out + assert "never cited code" in out + + +# ─── limits, modes, diagnostics ───────────────────────────────────────── + + +def test_staged_mode_reads_the_index(tmp_path): + repo = _git_repo(tmp_path, {"docs/a.md": "start\n", "src/m.py": "def f():\n return 1\n"}) + (repo / "docs" / "a.md").write_text("start\nSee `src/nope.py`.\n", encoding="utf-8") + subprocess.run(["git", "add", "docs/a.md"], cwd=str(repo), check=True, capture_output=True) + proc = subprocess.run( + [sys.executable, str(SCRIPT), "--repo", str(repo), "--staged"], + capture_output=True, text=True, encoding="utf-8", errors="replace", + ) + assert proc.returncode == 1, proc.stdout + proc.stderr + assert "docs/a.md" in proc.stdout + + +def test_too_many_files_is_refused(tmp_path): + _make_repo(tmp_path) + diff = "".join(_diff(f"docs/en/f{i}.md", ["See `src/core/search/engine.py`."]) for i in range(201)) + code, out = _run(tmp_path, diff) + assert code == 3, out + assert "refusing to pass" in out + + +def test_too_many_added_lines_is_refused(tmp_path): + _make_repo(tmp_path) + diff = _diff("docs/en/big.md", ["See `src/core/search/engine.py`."] * 20_001) + code, out = _run(tmp_path, diff) + assert code == 3, out + assert "refusing to pass" in out + + +def test_non_utf8_diff_file_fails_loudly(tmp_path): + _make_repo(tmp_path) + diff_file = tmp_path / "utf16.diff" + diff_file.write_bytes(_diff("docs/en/x.md", ["See `src/core/nope.py`."]).encode("utf-16")) + proc = subprocess.run( + [sys.executable, str(SCRIPT), "--repo", str(tmp_path), "--diff-file", str(diff_file)], + capture_output=True, text=True, encoding="utf-8", errors="replace", + ) + assert proc.returncode == 3, proc.stdout + proc.stderr + assert "not UTF-8" in (proc.stdout + proc.stderr) + + +def test_repo_without_installer_does_not_crash(tmp_path): + (tmp_path / "docs" / "en").mkdir(parents=True) + (tmp_path / "src").mkdir() + code, out = _run(tmp_path, _diff("docs/en/x.md", ["See `install.py --clean`."])) + assert code == 1, out + + +def test_json_output_shape(tmp_path): + _make_repo(tmp_path) + code, out = _run(tmp_path, _diff("docs/en/x.md", ["See `src/core/search/engine.py`."]), "--json") + payload = json.loads(out) + assert code == 0 + assert payload["files"] == 1 and payload["problems"] == [] + + +def test_gate_flags_a_real_offending_change_in_this_repo(tmp_path): + """Positive control on the real tree: an invented reference must fail.""" + diff_file = tmp_path / "real.diff" + diff_file.write_text( + _diff("docs/en/INSTALL.md", ["Run `install.py --uninstall` to remove everything."]), + encoding="utf-8", + ) + proc = subprocess.run( + [sys.executable, str(SCRIPT), "--repo", str(REPO), "--diff-file", str(diff_file)], + capture_output=True, text=True, encoding="utf-8", errors="replace", + ) + assert proc.returncode == 1, proc.stdout + proc.stderr