From 8c8eaad08de66e906ac1e9ed92715e881a1c9f97 Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Mon, 28 Sep 2026 22:04:48 +0300 Subject: [PATCH 1/2] fix(githooks): restore pre-commit hook and resolve repo root by marker walk Restore .githooks/pre-commit with find_project_root() marker walk (.git/KNOWN_ISSUES.md) so the hook resolves the repo root from any home; legacy depth fallback kept. --- .githooks/pre-commit | 103 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100755 .githooks/pre-commit diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..473809a7 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,103 @@ +#!/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: + """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. Falls back to the legacy depth + (parent.parent.parent, correct for .git/hooks/ home) if no + marker is found. + """ + 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 Path(__file__).resolve().parent.parent.parent + + +def run_script(script_path: str, label: str) -> bool: + """Запускает скрипт и возвращает True если успешно.""" + project_root = find_project_root() + 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() From 0b6ca7c4dd16b42eb4184a2ead4485b776e242f9 Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Mon, 28 Sep 2026 22:34:33 +0300 Subject: [PATCH 2/2] fix(redteam): fail-closed hook, STARTUPINFO guards, holdout timeouts --- .githooks/pre-commit | 12 ++-- KNOWN_ISSUES.md | 25 +++++++- scripts/o1_holdout_gate.py | 22 ++++++- scripts/p2_holdout_gate.py | 19 +++++- src/core/silent_subprocess.py | 84 +++++++++++++++++++++++++ tests/test_holdout_harness_timeout.py | 89 +++++++++++++++++++++++++++ tests/test_hook_root.py | 68 ++++++++++++++++++++ tests/test_silent_subprocess.py | 83 +++++++++++++++++++++++++ 8 files changed, 391 insertions(+), 11 deletions(-) create mode 100644 src/core/silent_subprocess.py create mode 100644 tests/test_holdout_harness_timeout.py create mode 100644 tests/test_hook_root.py create mode 100644 tests/test_silent_subprocess.py diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 473809a7..f73d166a 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -28,24 +28,26 @@ if sys.stdout.encoding != 'utf-8': sys.stdout.reconfigure(encoding='utf-8', errors='replace') -def find_project_root() -> Path: +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. Falls back to the legacy depth - (parent.parent.parent, correct for .git/hooks/ home) if no - marker is found. + 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 Path(__file__).resolve().parent.parent.parent + 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(): diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 201c08e1..30de2694 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -5,6 +5,29 @@ --- +## 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 = ` → `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): повтор того же запроса в том же процессе отдаёт закэшированные скоры, а не измеряет код. @@ -12,7 +35,7 @@ - **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) diff --git a/scripts/o1_holdout_gate.py b/scripts/o1_holdout_gate.py index 03998350..7bddef0f 100644 --- a/scripts/o1_holdout_gate.py +++ b/scripts/o1_holdout_gate.py @@ -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} @@ -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 @@ -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 @@ -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')}") diff --git a/scripts/p2_holdout_gate.py b/scripts/p2_holdout_gate.py index 986b3183..455a8c10 100644 --- a/scripts/p2_holdout_gate.py +++ b/scripts/p2_holdout_gate.py @@ -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, ) @@ -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 @@ -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 = [ @@ -135,6 +149,7 @@ async def _run_all(searcher): "wall": round(wall, 2), "void": void, "degraded": degraded, + "timed_out": timed_out, } ) print( diff --git a/src/core/silent_subprocess.py b/src/core/silent_subprocess.py new file mode 100644 index 00000000..e5e52ab0 --- /dev/null +++ b/src/core/silent_subprocess.py @@ -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() diff --git a/tests/test_holdout_harness_timeout.py b/tests/test_holdout_harness_timeout.py new file mode 100644 index 00000000..c105c06a --- /dev/null +++ b/tests/test_holdout_harness_timeout.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Holdout-harness per-query timeout guard (2026-09-28). + +Regression: one hung `hybrid_search_async` must fail its row (timed_out, +evaluated like void/degraded in main()) — never hang the whole gate. +Style follows tests/test_holdout_harness_loop.py: fake searchers, no live +services. Timeout is overridden via monkeypatch — never waits 120s in test. +""" + +import asyncio +import importlib +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +GATES = ["scripts.o1_holdout_gate", "scripts.p2_holdout_gate"] + +WARMUP = "warmup cold start primer" + + +class _FakeCache: + def clear(self): + pass + + +class _BaseFake: + def __init__(self): + self._reranker_cache = _FakeCache() + self._multi_reranker = None + self._last_rerank_timing = {"reranker_ms": 12.5, "model": "fake"} + + def _build_fts5_index(self): + pass + + def _row(self, query): + return [{"metadata": {"file": "x.py"}, "identifier_boost": False}] + + +class _HangingSearcher(_BaseFake): + """Hangs on every measured query; warm-up stays fast (one-hung-query model).""" + + async def hybrid_search_async(self, query, limit=5): + if query == WARMUP: + return self._row(query) + await asyncio.sleep(60) + return self._row(query) # pragma: no cover — cancelled by wait_for + + +class _FastSearcher(_BaseFake): + async def hybrid_search_async(self, query, limit=5): + return self._row(query) + + +@pytest.mark.parametrize("module_name", GATES) +def test_hung_query_becomes_timed_out_fail_row(module_name, monkeypatch): + """A hung query yields timed_out rows quickly (short timeout override).""" + mod = importlib.import_module(module_name) + monkeypatch.setattr(mod, "QUERY_TIMEOUT", 0.05) + rows = asyncio.run(mod._run_all(_HangingSearcher())) + assert len(rows) > 1 + assert all(r["timed_out"] for r in rows), "hung queries must flag timed_out" + + +@pytest.mark.parametrize("module_name", GATES) +def test_fast_searcher_no_timeout_positive_control(module_name, monkeypatch): + """Positive control: a fast searcher never flags timed_out.""" + mod = importlib.import_module(module_name) + monkeypatch.setattr(mod, "QUERY_TIMEOUT", 5) + rows = asyncio.run(mod._run_all(_FastSearcher())) + assert len(rows) > 1 + assert all(not r["timed_out"] for r in rows) + + +@pytest.mark.parametrize("module_name", GATES) +def test_main_fails_gate_on_timeout(module_name, monkeypatch, capsys): + """main() evaluates timed_out rows like void/degraded -> GATE FAIL, exit 1.""" + mod = importlib.import_module(module_name) + monkeypatch.setattr(mod, "QUERY_TIMEOUT", 0.05) + monkeypatch.setattr(mod, "build_searcher", lambda project: _HangingSearcher()) + monkeypatch.setattr(sys, "argv", ["gate"]) + assert mod.main() == 1 + out = capsys.readouterr().out + assert "timed out" in out + assert "GATE FAIL" in out diff --git a/tests/test_hook_root.py b/tests/test_hook_root.py new file mode 100644 index 00000000..a91b85ee --- /dev/null +++ b/tests/test_hook_root.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Pre-commit hook fail-closed guard (2026-09-28). + +Regression: when neither `.git` nor `KNOWN_ISSUES.md` markers are found +(renamed tree, copied tree, broken `.git`), the hook must FAIL CLOSED +(exit != 0, explicit "project root not found") — never print 9x +"script not found" + "All pre-commit checks passed" with exit 0. +""" + +import importlib.util +import shutil +import subprocess +import sys +from importlib.machinery import SourceFileLoader +from pathlib import Path + +HOOK = Path(__file__).resolve().parent.parent / ".githooks" / "pre-commit" + + +def _load_hook(): + # Extensionless hook file -> SourceFileLoader (spec_from_file_location + # returns a None-spec with no known loader for such paths). + loader = SourceFileLoader("hook_under_test", str(HOOK)) + spec = importlib.util.spec_from_loader("hook_under_test", loader) + mod = importlib.util.module_from_spec(spec) + loader.exec_module(mod) + return mod + + +def test_find_project_root_none_in_markerless_tmpdir(tmp_path): + """find_project_root() returns None when no markers exist above __file__.""" + mod = _load_hook() + # Simulate hook living in a markerless tree: point __file__ under tmp_path. + mod.__file__ = str(tmp_path / "hooks" / "pre-commit") + assert mod.find_project_root() is None + + +def test_hook_exits_nonzero_in_markerless_tmpdir(tmp_path): + """Safe repro from the investigation: copied hook must fail closed.""" + work = tmp_path / "copytree" + work.mkdir() + copied = work / "pre-commit" + shutil.copy(HOOK, copied) + # Sanity: no markers anywhere above the copy inside tmp_path, and the + # walk must not escape into a real repo — tmp_path itself is markerless. + assert not (work / ".git").exists() + assert not (work / "KNOWN_ISSUES.md").exists() + proc = subprocess.run( + [sys.executable, str(copied)], + capture_output=True, + timeout=120, + cwd=str(tmp_path), + # Hook forces utf-8 stdout (emoji); parent must decode the same — + # default cp1251 on Win loses bytes and yields stdout=None. + encoding="utf-8", + errors="replace", + ) + assert proc.returncode != 0, f"hook passed fail-open:\n{proc.stdout}" + assert "All pre-commit checks passed" not in proc.stdout + assert "project root not found" in proc.stdout + + +def test_find_project_root_resolves_real_repo(): + """Positive control: in-repo, find_project_root() returns a real Path.""" + mod = _load_hook() + root = mod.find_project_root() + assert root is not None + assert (root / "KNOWN_ISSUES.md").is_file() diff --git a/tests/test_silent_subprocess.py b/tests/test_silent_subprocess.py new file mode 100644 index 00000000..8767360c --- /dev/null +++ b/tests/test_silent_subprocess.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""silent_subprocess STARTUPINFO guards (2026-09-28). + +Regression S1: `subprocess.STARTUPINFO()` ctor must live INSIDE the narrowed +try in `_silent_startupinfo` — on an exotic win32 build without STARTUPINFO, +`apply()` + wrapper use must not raise (missing ctor -> startupinfo=None, +which Popen accepts as default). S2: the `_SilentPopen` setdefault must be +wrapped exactly like the `_with_flags` one (except AttributeError/OSError/ +TypeError). + +Positive control: with STARTUPINFO present, the wrappers still inject a real +`startupinfo` object into the underlying call. + +Note: root conftest replaces subprocess.Popen with a plain function during +tests (_no_console_windows) — these tests install their own dummy Popen +CLASS so apply()'s subclassing is exercised faithfully without spawning. +""" + +import subprocess +import sys + +import pytest + +import src.core.silent_subprocess as mod + +pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="silent_subprocess is win32-only") + + +class _DummyPopen: + """Stand-in Popen class: records kwargs, spawns nothing.""" + + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + + +@pytest.fixture +def isolated_apply(monkeypatch): + """Dummy Popen class + save/restore the four patched attrs and _APPLIED.""" + monkeypatch.setattr(subprocess, "Popen", _DummyPopen) + for name in ("run", "check_output", "check_call"): + monkeypatch.setattr(subprocess, name, getattr(subprocess, name)) + monkeypatch.setattr(mod, "_APPLIED", False) + return monkeypatch + + +def test_apply_and_popen_no_raise_without_startupinfo(isolated_apply, monkeypatch): + """S1+S2: missing subprocess.STARTUPINFO -> apply() and use must not raise.""" + monkeypatch.delattr(subprocess, "STARTUPINFO", raising=False) + assert not hasattr(subprocess, "STARTUPINFO") + mod.apply() # must not raise + inst = subprocess.Popen(["echo", "hi"]) # S2 path: must not raise either + assert isinstance(inst, _DummyPopen) + assert inst.kwargs.get("startupinfo") is None + + +def test_popen_injects_startupinfo_when_present(isolated_apply): + """Positive control (Popen path): real startupinfo object is injected.""" + assert hasattr(subprocess, "STARTUPINFO"), "positive control needs STARTUPINFO" + mod.apply() + inst = subprocess.Popen(["echo", "hi"]) + assert inst.kwargs.get("startupinfo") is not None + assert inst.kwargs.get("creationflags") == getattr(subprocess, "CREATE_NO_WINDOW", 0) + + +def test_run_wrapper_injects_startupinfo_when_present(isolated_apply, monkeypatch): + """Positive control (run path): _with_flags still passes startupinfo.""" + calls = {} + + def fake_run(*args, **kwargs): + calls.update(kwargs) + + class _R: + returncode = 0 + + return _R() + + assert hasattr(subprocess, "STARTUPINFO"), "positive control needs STARTUPINFO" + monkeypatch.setattr(subprocess, "run", fake_run) + monkeypatch.setattr(mod, "_APPLIED", False) + mod.apply() + subprocess.run(["echo", "hi"]) + assert calls.get("startupinfo") is not None