Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#!/usr/bin/env python
"""
MSCodeBase pre-commit hook — автоматическая проверка перед коммитом.

Установлен: 3.4.0
Дата установки: 2026-09-04 11:19:11

Запускает:
1. verify_diary — проверка AGENT_DIARY.md
2. stale_detector — проверка дрейфа версий в доках
3. check_tool_names — semantic-гейт имён MCP-тулов
4. negative_controls — guard inventory (каждый guard умеет падать)
5. check_layer_boundaries — гейт трёх осей (Universal Engine)
6. architecture_linter — архитектурные инварианты (core→mcp, registry, циклы, stale-имена)
7. lock_guard — активные git-локи (advisory, exit 0)
8. check_known_issues — §4.8 R4: размер ≤ 300 строк + архивация старых записей
"""

import subprocess
import sys
from pathlib import Path


# §9 п.9 (ENCODING SAFETY): при выводе emoji-строк из stdout скриптов
# (например, «📊 Итог: 20 ✅ / 1 ❌») в cp1251-консоль падает
# UnicodeEncodeError → hook фейлит коммит по ложной причине.
if sys.stdout.encoding != 'utf-8':
sys.stdout.reconfigure(encoding='utf-8', errors='replace')


def find_project_root() -> Path | None:
"""Resolve repo root by marker walk (works from any hook home).

Ascends from the hook file until a directory containing .git or
KNOWN_ISSUES.md is found. Returns None when no marker is found
(fail-closed: the caller must refuse to pass, never silently skip).
"""
start = Path(__file__).resolve().parent
for candidate in [start, *start.parents]:
if (candidate / ".git").exists() or (candidate / "KNOWN_ISSUES.md").is_file():
return candidate
return None


def run_script(script_path: str, label: str) -> bool:
"""Запускает скрипт и возвращает True если успешно."""
project_root = find_project_root()
if project_root is None:
print(f" ❌ {label}: project root not found (no .git / KNOWN_ISSUES.md markers)")
return False
script = project_root / script_path

if not script.exists():
print(f" ⏭️ {label}: скрипт не найден ({script})")
return True

# §5.16: Popen + communicate (не capture_output) — защита от pipe-deadlock
# в фоновых потоках; encoding="utf-8" — декодирование stdout в utf-8.
proc = subprocess.Popen(
[sys.executable, str(script)],
cwd=str(project_root),
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
encoding="utf-8",
errors="replace",
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0),
)
# Таймаут-запас: verify_diary гоняет gate-zero (полный pytest ~108-130s под
# нагрузкой) — кап 120s давал флаки TimeoutExpired на коммитах (2026-08-08);
# 300→900 (2026-08-24): сюита выросла (live-sync + predict-наборы), 300s
# начал флакать при параллельной нагрузке.
stdout, _ = proc.communicate(timeout=900)
if proc.returncode != 0:
print(f" ❌ {label}: exit {proc.returncode}")
if stdout:
for line in stdout.splitlines()[-10:]:
print(f" {line}")
return False
print(f" ✅ {label}: OK")
return True


def main():
print("🔍 MSCodeBase pre-commit checks:")
all_ok = True

all_ok &= run_script("scripts/verify_diary.py", "verify_diary")
all_ok &= run_script("scripts/stale_detector.py", "stale_detector")
all_ok &= run_script("scripts/check_tool_names.py", "check_tool_names")
all_ok &= run_script("scripts/negative_controls_runner.py", "negative_controls")
all_ok &= run_script("scripts/check_layer_boundaries.py", "check_layer_boundaries")
all_ok &= run_script("scripts/architecture_linter.py", "architecture_linter")
all_ok &= run_script("scripts/lock_guard.py", "lock_guard (advisory)")
all_ok &= run_script("scripts/check_known_issues.py", "check_known_issues")
all_ok &= run_script("scripts/ruff_gate.py", "ruff_gate")

if not all_ok:
print("\n❌ Pre-commit checks FAILED. Исправьте ошибки перед коммитом.")
sys.exit(1)
print("\n✅ All pre-commit checks passed.")
sys.exit(0)


if __name__ == "__main__":
main()
25 changes: 24 additions & 1 deletion KNOWN_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,37 @@

---

## 2026-09-28 — Pre-commit hook fail-open при потере маркеров (Open)

- **Локация:** `.githooks/pre-commit:31-53` (`find_project_root` + `run_script`).
- **Симптом:** если ни `.git`, ни `KNOWN_ISSUES.md` не найдены (переименование, копия дерева, битый `.git`), fallback указывает мимо проекта; все 9 гейтов печатают ⏭️ «скрипт не найден» и возвращают True → «All pre-commit checks passed», exit 0, коммит идёт без единой проверки.
- **Repro (Verified 2026-09-28):** копия хука в `%TEMP%` (без маркеров) → 9× «скрипт не найден», итог PASS.
- **Guard:** fallback-ветвь обязана fail-closed (sys.exit(1) с явным «project root not found»), либо `run_script` считает missing-script провалом, когда пропущены ВСЕ скрипты; regression-тест: исполнение с `__file__` в markerless-tmpdir → exit ≠ 0.
- **Статус:** 🟡 Fixed-pending-verification (branch `fix/redteam-open-triple`: `find_project_root() -> Path | None`, `run_script` fail-closed; `tests/test_hook_root.py` 3/3 green + full suite 1940 passed).

## 2026-09-28 — silent_subprocess: STARTUPINFO ctor outside narrowed try (Open)

- **Локация:** `src/core/silent_subprocess.py:32-33` (S1), `:51` (S2 — unwrapped `setdefault`).
- **Симптом:** `subprocess.STARTUPINFO()` на L33 вне `try`; на экзотическом win32-билде без `STARTUPINFO` — `AttributeError` из `apply()` на импорте (S2/L51 тот же путь без обёртки; S4/L67 в безопасности — вызов внутри try).
- **Контекст:** на CPython/win32 `STARTUPINFO` всегда есть; все реальные `creationflags=`-вызывающие передают int — практический риск ≈ 0.
- **Guard:** перенести конструирование внутрь try (S1) + обернуть L51 как L67; regression-тест: monkeypatch `subprocess.STARTUPINFO = <missing>` → `apply()` не бросает.
- **Статус:** 🟡 Fixed-pending-verification (low; branch `fix/redteam-open-triple`: ctor inside try + `si = None` init, `:51` wrapped like `:66-69`; `tests/test_silent_subprocess.py` 3/3 green + full suite 1940 passed).

## 2026-09-28 — o1_holdout_gate: hung query hangs whole gate, no timeout (Open)

- **Локация:** `scripts/o1_holdout_gate.py:129-156` (`_run_all`), вызов L106.
- **Симптом:** 15 запросов идут последовательно в одном loop без `wait_for`/глобального капа; один зависший `hybrid_search_async` вешает весь гейт навсегда (единственный `timeout=10` — git-rev диагностика, L99-101).
- **Guard:** per-query `asyncio.wait_for(..., timeout=120)` + timeout → fail-row (как `degraded`); regression — фейковый searcher с висящим запросом → гейт падает за ~120с, а не висит.
- **Статус:** 🟡 Fixed-pending-verification (medium — CI-stall; branch `fix/redteam-open-triple`: per-query `wait_for(timeout=120)` + `timed_out` fail-row in both gates; `tests/test_holdout_harness_timeout.py` 6/6 green + full suite 1940 passed).

## 2026-09-28 — Ретриевер-замеры без сброса реранкер-кэша недействительны (Open)

- **Правило:** все retriever-замеры и A/B-тесты — только в свежем процессе либо с явным сбросом реранкер-кэша (`Searcher._reranker_cache.clear()`). Ключ кэша включает текст запроса (engine.py:1646): повтор того же запроса в том же процессе отдаёт закэшированные скоры, а не измеряет код.
- **Эвристика void-замера:** wall <2s на `hybrid_search_async` при ожидании полного пайплайна (embed+BM25+FTS+rerank) = подозрение на cache hit; сверяться с `Searcher._last_rerank_timing` (пусто = реранкер не работал). Холодный FTS-билд (~2.5s) — обратная ловушка: ПЕРВЫЙ замер в свежем процессе молча теряет FTS-тир (2s `wait_for`), нужен discarded warm-up на чужом запросе.
- **Harness-ловушка (2026-09-28, Verified):** `asyncio.run()` на КАЖДЫЙ запрос роняет чётные запросы в reranker-passthrough (`reranker_ms=0`, `model='-'`, возврат пула без скоринга) — детерминировано по паритету позиции, свежая/здоровая инфра, флаги провайдера в норме. Серия обязана идти в ОДНОМ event loop; плюс явный degraded-флаг (`not reranker_ms` → замер недействителен). Void-флаг (`timing=={}`) этот класс НЕ ловит (timing={ms:0,...} ≠ {}).
- **Статус:** 🟡 Open (процедурное правило; guard-скрипт `scripts/o1_holdout_gate.py` — fresh-process + warm-up + void-флаг).

**21 entries** — compressed per §4.8 R3 (conclusion-first; dedup 2026-09-08, 2026-09-21). Closed entries moved to docs/archive/KNOWN_ISSUES_2026_09.md on 2026-09-27 (R1 size guard; second batch on merge experiment/4a-unit-of-return).
**24 entries** — compressed per §4.8 R3 (conclusion-first; dedup 2026-09-08, 2026-09-21). Closed entries moved to docs/archive/KNOWN_ISSUES_2026_09.md on 2026-09-27 (R1 size guard; second batch on merge experiment/4a-unit-of-return).

## 2026-09-27 — Import-time os.environ mutation in scripts breaks xdist workers (Fixed)

Expand Down
22 changes: 19 additions & 3 deletions scripts/o1_holdout_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))

# Per-query cap: one hung hybrid_search_async must fail its row, never the gate.
QUERY_TIMEOUT = 120

P2 = {"id": "P2", "query": "hybrid_search_async reciprocal_rank_fusion FTS5 BM25",
"target": "src/core/search/engine.py", "expect_rank": 1}

Expand Down Expand Up @@ -119,6 +122,8 @@ def main() -> int:
fails.append(f"{row['id']} reranker-cache void (wall<2s, no timing)")
if row["degraded"]:
fails.append(f"{row['id']} reranker degraded (reranker_ms=0, passthrough)")
if row["timed_out"]:
fails.append(f"{row['id']} query timed out (>{QUERY_TIMEOUT}s)")
if fails:
print("GATE FAIL: " + "; ".join(fails))
return 1
Expand All @@ -132,11 +137,22 @@ async def _run_all(searcher):
# on main and would silently drop the FTS tier for the FIRST measured query
# (known issue, PR52 territory). Warm-up uses a disjoint query -> no
# reranker-cache contamination (cache key includes the query text).
await searcher.hybrid_search_async("warmup cold start primer", limit=3)
await asyncio.wait_for(
searcher.hybrid_search_async("warmup cold start primer", limit=3),
timeout=QUERY_TIMEOUT,
)
rows = []
for case in [P2, *HOLDOUT]:
t0 = time.perf_counter()
results = await searcher.hybrid_search_async(case["query"], limit=5)
try:
results = await asyncio.wait_for(
searcher.hybrid_search_async(case["query"], limit=5),
timeout=QUERY_TIMEOUT,
)
timed_out = False
except asyncio.TimeoutError:
results = []
timed_out = True
wall = time.perf_counter() - t0
boosted = [r for r in results if r.get("identifier_boost")]
doc_boosted = [r for r in boosted
Expand All @@ -149,7 +165,7 @@ async def _run_all(searcher):
rows.append({"id": case["id"], "rank": rank, "target": case.get("target"),
"n_boost": len(boosted), "n_doc_boost": len(doc_boosted),
"n": len(results), "wall": round(wall, 2),
"void": void, "degraded": degraded})
"void": void, "degraded": degraded, "timed_out": timed_out})
print(f"{case['id']:>3} rank={rank} n={len(results)} target={case.get('target')} "
f"boost={len(boosted)} doc_boost={len(doc_boosted)} wall={wall:.2f}s "
f"rerank_ms={timing.get('reranker_ms')} model={timing.get('model')}")
Expand Down
19 changes: 17 additions & 2 deletions scripts/p2_holdout_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
from scripts.o1_holdout_gate import ( # noqa: E402 — imported for the case table
HOLDOUT,
P2,
QUERY_TIMEOUT,
build_searcher,
rank_of,
)
Expand Down Expand Up @@ -91,6 +92,8 @@ def main() -> int:
fails.append(f"{row['id']} reranker-cache void (wall<2s, no timing)")
if row["degraded"]:
fails.append(f"{row['id']} reranker degraded (reranker_ms=0, passthrough)")
if row["timed_out"]:
fails.append(f"{row['id']} query timed out (>{QUERY_TIMEOUT}s)")
if fails:
print("GATE FAIL: " + "; ".join(fails))
return 1
Expand All @@ -105,12 +108,23 @@ async def _run_all(searcher):
await asyncio.to_thread(searcher._build_fts5_index)
print(f"FTS prebuild (discarded): {time.perf_counter() - t0:.2f}s")
# Discarded warm-up on a disjoint query (cache key includes query text).
await searcher.hybrid_search_async("warmup cold start primer", limit=3)
await asyncio.wait_for(
searcher.hybrid_search_async("warmup cold start primer", limit=3),
timeout=QUERY_TIMEOUT,
)
rows = []
for case in [P2, *HOLDOUT]:
searcher._reranker_cache.clear()
t0 = time.perf_counter()
results = await searcher.hybrid_search_async(case["query"], limit=5)
try:
results = await asyncio.wait_for(
searcher.hybrid_search_async(case["query"], limit=5),
timeout=QUERY_TIMEOUT,
)
timed_out = False
except asyncio.TimeoutError:
results = []
timed_out = True
wall = time.perf_counter() - t0
boosted = [r for r in results if r.get("identifier_boost")]
doc_boosted = [
Expand All @@ -135,6 +149,7 @@ async def _run_all(searcher):
"wall": round(wall, 2),
"void": void,
"degraded": degraded,
"timed_out": timed_out,
}
)
print(
Expand Down
84 changes: 84 additions & 0 deletions src/core/silent_subprocess.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""silent_subprocess.py — глобальный guard от мигающих консолей на Windows.

Патчит subprocess.Popen/run/check_output/check_call: на win32 всегда
добавляет CREATE_NO_WINDOW + STARTUPINFO(SW_HIDE), если вызывающий их
не задал явно. Импортировать ПЕРВЫМ в entry-point (src/main.py).

Идемпотентен: повторный import/apply() — no-op.
"""
from __future__ import annotations

import subprocess
import sys

_APPLIED = False


def apply() -> None:
global _APPLIED
if _APPLIED:
return
_APPLIED = True
if sys.platform != "win32":
return

_CNW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
_SW_HIDE = getattr(subprocess, "SW_HIDE", 0) or 0
try:
from subprocess import STARTF_USESHOWWINDOW as _SW_FLAG # type: ignore
except ImportError:
_SW_FLAG = 0

def _silent_startupinfo():
si = None
try:
si = subprocess.STARTUPINFO() # type: ignore[attr-defined]
si.dwFlags |= _SW_FLAG
si.wShowWindow = _SW_HIDE
except (AttributeError, TypeError):
pass
return si

_orig_popen = subprocess.Popen

class _SilentPopen(_orig_popen): # type: ignore[misc]
def __init__(self, *args, **kwargs):
kwargs.setdefault("creationflags", _CNW)
# CREATE_NO_WINDOW может быть скомбинирован — OR, не замена
try:
kwargs["creationflags"] |= _CNW
except TypeError:
pass
try:
kwargs.setdefault("startupinfo", _silent_startupinfo())
except (AttributeError, OSError, TypeError):
pass
super().__init__(*args, **kwargs)

_orig_run = subprocess.run
_orig_check_output = subprocess.check_output
_orig_check_call = subprocess.check_call

def _with_flags(fn):
def wrapper(*args, **kwargs):
kwargs.setdefault("creationflags", _CNW)
try:
kwargs["creationflags"] |= _CNW
except TypeError:
pass
# startupinfo поддерживают Popen/run/check_* — os-уровень, безопасно
try:
kwargs.setdefault("startupinfo", _silent_startupinfo())
except (AttributeError, OSError, TypeError):
pass
return fn(*args, **kwargs)

return wrapper

subprocess.Popen = _SilentPopen # type: ignore[misc]
subprocess.run = _with_flags(_orig_run) # type: ignore[method-assign]
subprocess.check_output = _with_flags(_orig_check_output) # type: ignore[method-assign]
subprocess.check_call = _with_flags(_orig_check_call) # type: ignore[method-assign]


apply()
Loading
Loading