From 076e10a8747439cee3f501b6d3b04a570b094318 Mon Sep 17 00:00:00 2001 From: rotatedcoded Date: Tue, 14 Jul 2026 12:13:02 +0700 Subject: [PATCH] Add release candidate safety checker --- check_release_candidate.py | 7 + src/runtime/release_candidate_check.py | 392 +++++++++++++++++++++++++ tests/test_release_candidate_check.py | 177 +++++++++++ 3 files changed, 576 insertions(+) create mode 100644 check_release_candidate.py create mode 100644 src/runtime/release_candidate_check.py create mode 100644 tests/test_release_candidate_check.py diff --git a/check_release_candidate.py b/check_release_candidate.py new file mode 100644 index 0000000..c2c2b2c --- /dev/null +++ b/check_release_candidate.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from src.runtime.release_candidate_check import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/runtime/release_candidate_check.py b/src/runtime/release_candidate_check.py new file mode 100644 index 0000000..26bcd7f --- /dev/null +++ b/src/runtime/release_candidate_check.py @@ -0,0 +1,392 @@ +from __future__ import annotations + +import argparse +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Iterable, Sequence + +from src.engine.stockfish_service import DEFAULT_ENGINE_PATH + + +GENERATED_DATA_ROOTS = ( + "data/datasets", + "data/personas", + "data/analysis", + "data/ml", +) + +ALLOWED_GENERATED_PLACEHOLDERS = ( + "data/datasets/.gitkeep", + "data/personas/.gitkeep", + "data/analysis/.gitkeep", + "data/ml/.gitkeep", +) + +REQUIRED_SOURCE_FILES = ( + "app.py", + "run_full_persona_pipeline.py", + "check_runtime_readiness.py", + "src/runtime/readiness.py", + "src/ui/unified_persona_pipeline.py", + "src/ui/persona_creation_worker.py", + "src/engine/stockfish_service.py", +) + + +@dataclass(frozen=True) +class ReleaseCandidateCheck: + name: str + ok: bool + status: str + required: bool = True + detail: str = "" + + +@dataclass(frozen=True) +class ReleaseCandidateReport: + project_root: Path + checks: tuple[ReleaseCandidateCheck, ...] + + @property + def ready(self) -> bool: + return all(check.ok for check in self.checks if check.required) + + @property + def exit_code(self) -> int: + return 0 if self.ready else 2 + + +GitLinesFunc = Callable[[Path, tuple[str, ...]], list[str]] + + +def normalize_repo_path(path: object) -> str: + return str(path or "").replace("\\", "/").strip().lstrip("./") + + +def is_generated_data_path(path: object) -> bool: + normalized = normalize_repo_path(path).lower() + return any( + normalized == root or normalized.startswith(root + "/") + for root in GENERATED_DATA_ROOTS + ) + + +def is_allowed_generated_placeholder_path(path: object) -> bool: + normalized = normalize_repo_path(path).lower() + return normalized in ALLOWED_GENERATED_PLACEHOLDERS + + +def generated_data_paths(paths: Iterable[object]) -> tuple[str, ...]: + return tuple( + normalize_repo_path(path) + for path in paths + if is_generated_data_path(path) + and not is_allowed_generated_placeholder_path(path) + ) + + +def git_lines( + project_root: Path, + args: tuple[str, ...], +) -> list[str]: + try: + completed = subprocess.run( + ("git", *args), + cwd=project_root, + check=False, + text=True, + encoding="utf-8", + errors="replace", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except OSError: + return [] + + if completed.returncode != 0: + return [] + + return [ + line.strip() + for line in completed.stdout.splitlines() + if line.strip() + ] + + +def _path_check( + *, + project_root: Path, + relative_path: str, + name: str, + required: bool = True, +) -> ReleaseCandidateCheck: + path = project_root / relative_path + ok = path.exists() + return ReleaseCandidateCheck( + name=name, + ok=ok, + status="OK" if ok else "MISSING", + required=required, + detail=str(path), + ) + + +def _source_files_check( + project_root: Path, +) -> ReleaseCandidateCheck: + missing = [ + path + for path in REQUIRED_SOURCE_FILES + if not (project_root / path).exists() + ] + if not missing: + return ReleaseCandidateCheck( + name="Required source files", + ok=True, + status="OK", + detail=f"{len(REQUIRED_SOURCE_FILES)} files found", + ) + + return ReleaseCandidateCheck( + name="Required source files", + ok=False, + status="MISSING", + detail="Missing: " + ", ".join(missing), + ) + + +def _staged_generated_data_check( + project_root: Path, + git_lines_func: GitLinesFunc, +) -> ReleaseCandidateCheck: + staged = git_lines_func( + project_root, + ("diff", "--cached", "--name-only"), + ) + forbidden = generated_data_paths(staged) + + if not forbidden: + return ReleaseCandidateCheck( + name="Generated data staged", + ok=True, + status="OK", + detail="No generated data is staged", + ) + + return ReleaseCandidateCheck( + name="Generated data staged", + ok=False, + status="BLOCKED", + detail=( + "Do not commit generated data: " + + ", ".join(forbidden[:20]) + ), + ) + + +def _tracked_generated_data_check( + project_root: Path, + git_lines_func: GitLinesFunc, +) -> ReleaseCandidateCheck: + tracked: list[str] = [] + for root in GENERATED_DATA_ROOTS: + tracked.extend( + git_lines_func( + project_root, + ("ls-files", root), + ) + ) + + forbidden = generated_data_paths(tracked) + + if not forbidden: + return ReleaseCandidateCheck( + name="Generated data tracked", + ok=True, + status="OK", + detail="No generated data is tracked by git except allowed .gitkeep placeholders", + ) + + return ReleaseCandidateCheck( + name="Generated data tracked", + ok=False, + status="BLOCKED", + detail=( + "Generated data should not be tracked: " + + ", ".join(forbidden[:20]) + ), + ) + + +def _dirty_tree_check( + project_root: Path, + git_lines_func: GitLinesFunc, + *, + allow_dirty_code: bool, +) -> ReleaseCandidateCheck: + status_lines = git_lines_func( + project_root, + ("status", "--short"), + ) + relevant = [ + line + for line in status_lines + if line + and not is_generated_data_path(line[3:] if len(line) > 3 else line) + ] + + if not relevant: + return ReleaseCandidateCheck( + name="Working tree", + ok=True, + status="CLEAN", + required=not allow_dirty_code, + detail="No dirty source files", + ) + + return ReleaseCandidateCheck( + name="Working tree", + ok=allow_dirty_code, + status="DIRTY", + required=not allow_dirty_code, + detail=( + "Dirty source files: " + + ", ".join(relevant[:20]) + ), + ) + + +def build_release_candidate_report( + *, + project_root: Path | str = ".", + engine_path: Path | str = DEFAULT_ENGINE_PATH, + allow_dirty_code: bool = False, + git_lines_func: GitLinesFunc = git_lines, +) -> ReleaseCandidateReport: + root = Path(project_root).resolve() + engine = Path(engine_path) + + checks = ( + _path_check( + project_root=root, + relative_path=".git", + name="Git repository", + ), + _source_files_check(root), + _path_check( + project_root=root, + relative_path=str(engine), + name="Stockfish binary", + ), + _staged_generated_data_check( + root, + git_lines_func, + ), + _tracked_generated_data_check( + root, + git_lines_func, + ), + _dirty_tree_check( + root, + git_lines_func, + allow_dirty_code=allow_dirty_code, + ), + ) + + return ReleaseCandidateReport( + project_root=root, + checks=checks, + ) + + +def render_release_candidate_report( + report: ReleaseCandidateReport, +) -> str: + title = "PASS" if report.ready else "NEEDS ATTENTION" + lines = [ + "ChessPersona Release Candidate Safety Check", + "=" * 48, + f"Status : {title}", + f"Project root: {report.project_root}", + "", + "Checks", + "-" * 48, + ] + + for check in report.checks: + marker = "OK" if check.ok else "BLOCKED" + optional = "" if check.required else " [warning]" + lines.append( + f"[{marker}] {check.name}: {check.status}{optional}" + ) + if check.detail: + lines.append(f" {check.detail}") + + lines.extend( + [ + "", + "Recommended validation", + "-" * 48, + "python -m pytest tests/test_ui_unified_persona_pipeline.py -vv", + "python -m pytest tests/test_runtime_readiness.py -vv", + "python -m pytest tests/test_stockfish_service_recovery.py -vv", + "python -m pytest tests/test_main_ui_elite_recommendation.py -vv", + "python .\\check_runtime_readiness.py --username jiqy --time-class blitz --engine-path engines\\stockfish\\stockfish.exe", + "", + "Reminder", + "-" * 48, + "Do not stage or commit generated local data:", + "data/datasets, data/personas, data/analysis, data/ml", + "Allowed placeholders:", + "data/datasets/.gitkeep, data/personas/.gitkeep, data/analysis/.gitkeep, data/ml/.gitkeep", + ] + ) + + return "\n".join(lines) + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Run local release-candidate safety checks before tagging " + "or opening a release PR." + ) + ) + parser.add_argument( + "--project-root", + default=".", + ) + parser.add_argument( + "--engine-path", + default=str(DEFAULT_ENGINE_PATH), + ) + parser.add_argument( + "--allow-dirty-code", + action="store_true", + help=( + "Report dirty source files as a warning instead of blocking. " + "Use this only while developing the safety check itself." + ), + ) + return parser + + +def run_from_args(args: argparse.Namespace) -> int: + report = build_release_candidate_report( + project_root=args.project_root, + engine_path=args.engine_path, + allow_dirty_code=bool(args.allow_dirty_code), + ) + print(render_release_candidate_report(report)) + return report.exit_code + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_arg_parser() + args = parser.parse_args(argv) + return run_from_args(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_candidate_check.py b/tests/test_release_candidate_check.py new file mode 100644 index 0000000..aaaa30e --- /dev/null +++ b/tests/test_release_candidate_check.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from pathlib import Path + +from src.runtime.release_candidate_check import ( + build_release_candidate_report, + generated_data_paths, + is_allowed_generated_placeholder_path, + is_generated_data_path, + normalize_repo_path, +) + + +def _make_required_files(root: Path) -> None: + for relative in ( + ".git", + "app.py", + "run_full_persona_pipeline.py", + "check_runtime_readiness.py", + "src/runtime/readiness.py", + "src/ui/unified_persona_pipeline.py", + "src/ui/persona_creation_worker.py", + "src/engine/stockfish_service.py", + "engines/stockfish/stockfish.exe", + ): + path = root / relative + if relative == ".git": + path.mkdir(parents=True) + continue + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("", encoding="utf-8") + + +def test_normalize_repo_path_uses_forward_slashes() -> None: + assert normalize_repo_path(r".\data\ml\model.json") == "data/ml/model.json" + + +def test_is_generated_data_path_detects_generated_roots() -> None: + assert is_generated_data_path("data/ml/model.joblib") + assert is_generated_data_path(r"data\datasets\jiqy\blitz.json") + assert is_generated_data_path("data/personas/jiqy/blitz_complete_persona.json") + assert is_generated_data_path("data/analysis/jiqy/blitz_move_quality.json") + assert not is_generated_data_path("src/ml/direct_ranker.py") + assert not is_generated_data_path("tests/test_release_candidate_check.py") + + +def test_allowed_generated_placeholders_are_not_forbidden() -> None: + assert is_allowed_generated_placeholder_path("data/datasets/.gitkeep") + assert is_allowed_generated_placeholder_path(r"data\personas\.gitkeep") + assert not is_allowed_generated_placeholder_path("data/datasets/jiqy/blitz.json") + + +def test_generated_data_paths_filters_only_forbidden_paths() -> None: + paths = generated_data_paths( + [ + "src/app.py", + "data/datasets/.gitkeep", + "data/ml/model.joblib", + "data/personas/jiqy/blitz.json", + ] + ) + + assert paths == ( + "data/ml/model.joblib", + "data/personas/jiqy/blitz.json", + ) + + +def test_release_candidate_report_passes_when_clean( + tmp_path: Path, +) -> None: + _make_required_files(tmp_path) + + def fake_git_lines(root: Path, args: tuple[str, ...]) -> list[str]: + return [] + + report = build_release_candidate_report( + project_root=tmp_path, + engine_path="engines/stockfish/stockfish.exe", + git_lines_func=fake_git_lines, + ) + + assert report.ready is True + assert report.exit_code == 0 + + +def test_release_candidate_report_allows_tracked_gitkeep_placeholders( + tmp_path: Path, +) -> None: + _make_required_files(tmp_path) + + def fake_git_lines(root: Path, args: tuple[str, ...]) -> list[str]: + if args == ("ls-files", "data/datasets"): + return ["data/datasets/.gitkeep"] + if args == ("ls-files", "data/personas"): + return ["data/personas/.gitkeep"] + if args == ("ls-files", "data/analysis"): + return ["data/analysis/.gitkeep"] + return [] + + report = build_release_candidate_report( + project_root=tmp_path, + engine_path="engines/stockfish/stockfish.exe", + git_lines_func=fake_git_lines, + ) + + assert report.ready is True + + +def test_release_candidate_report_blocks_staged_generated_data( + tmp_path: Path, +) -> None: + _make_required_files(tmp_path) + + def fake_git_lines(root: Path, args: tuple[str, ...]) -> list[str]: + if args == ("diff", "--cached", "--name-only"): + return ["data/ml/direct_candidate_models/jiqy/blitz.joblib"] + return [] + + report = build_release_candidate_report( + project_root=tmp_path, + engine_path="engines/stockfish/stockfish.exe", + git_lines_func=fake_git_lines, + ) + + assert report.ready is False + assert any( + check.name == "Generated data staged" and not check.ok + for check in report.checks + ) + + +def test_release_candidate_report_blocks_tracked_generated_data( + tmp_path: Path, +) -> None: + _make_required_files(tmp_path) + + def fake_git_lines(root: Path, args: tuple[str, ...]) -> list[str]: + if args == ("ls-files", "data/datasets"): + return ["data/datasets/jiqy/blitz_latest_100.json"] + return [] + + report = build_release_candidate_report( + project_root=tmp_path, + engine_path="engines/stockfish/stockfish.exe", + git_lines_func=fake_git_lines, + ) + + assert report.ready is False + assert any( + check.name == "Generated data tracked" and not check.ok + for check in report.checks + ) + + +def test_release_candidate_report_can_allow_dirty_code( + tmp_path: Path, +) -> None: + _make_required_files(tmp_path) + + def fake_git_lines(root: Path, args: tuple[str, ...]) -> list[str]: + if args == ("status", "--short"): + return [" M src/runtime/release_candidate_check.py"] + return [] + + report = build_release_candidate_report( + project_root=tmp_path, + engine_path="engines/stockfish/stockfish.exe", + allow_dirty_code=True, + git_lines_func=fake_git_lines, + ) + + assert report.ready is True + assert any( + check.name == "Working tree" and check.status == "DIRTY" + for check in report.checks + )