diff --git a/.claude/hooks/auto_fix_diagnostics.py b/.claude/hooks/auto_fix_diagnostics.py index 306fabc5..1ed056be 100644 --- a/.claude/hooks/auto_fix_diagnostics.py +++ b/.claude/hooks/auto_fix_diagnostics.py @@ -1,21 +1,22 @@ #!/usr/bin/env python3 """ -PostToolUse Auto-fix Hook — Detects type errors and surfaces them for fixing. +PostToolUse Auto-fix Hook — Detects errors and surfaces them for fixing. Two-hook system: - PostToolUse (this file) → runs pyright on edited file, saves errors to state + PostToolUse (this file) → runs pyright + ruff on edited file, saves errors to state PreToolUse (pre_edit_gate.py) → blocks edits to OTHER files until errors fixed Key behaviors: -- Runs py_compile (syntax), ruff (lint), pyright (type errors) on edited file +- Runs py_compile (syntax), ruff lint+format, pyright (type errors) on edited file - Runs seedgo checklist for AIPass standards -- Saves type errors to state file for PreToolUse gate +- Saves ruff lint AND pyright errors to state file for PreToolUse gate (hard block) - Surfaces ALL errors in additionalContext so Claude sees them -- Smart batching per-file -Version: 5.1.0 +Version: 5.2.0 CHANGELOG: + - v5.2.0 (2026-04-20): Save ruff lint errors to state file for hard-block enforcement. + Pre-edit gate now blocks on F401/lint just like type errors. - v5.1.0 (2026-04-19): Added ruff format --check to surface format drift. - v5.0.0 (2026-03-17): Replaced mcp__ide__getDiagnostics with direct pyright. Added state file for PreToolUse gate integration. @@ -145,6 +146,36 @@ def run_python_checks(file_path: str) -> list[str]: return errors + +def run_ruff_lint_structured(file_path: str) -> list[dict]: + """Run ruff check and return structured violations for the state file. + + Returns list of {line, message} dicts — same format as pyright errors. + Only non-empty when ruff finds real violations (not format drift). + """ + if '/.claude/hooks/' in file_path: + return [] + try: + result = subprocess.run( + ["ruff", "check", "--select=E,F,W", "--output-format=json", file_path], + capture_output=True, text=True, timeout=10 + ) + if not result.stdout.strip(): + return [] + violations = json.loads(result.stdout) + if not isinstance(violations, list): + return [] + errors = [] + for v in violations[:10]: + line = v.get("location", {}).get("row", 0) + code = v.get("code", "?") + message = v.get("message", "unknown")[:100] + errors.append({"line": line, "message": f"{code}: {message}"}) + return errors + except (FileNotFoundError, json.JSONDecodeError, subprocess.TimeoutExpired, Exception): + return [] + + def run_pyright_check(file_path: str) -> list[dict]: """Run pyright on a single file. Returns list of error dicts.""" # Skip hook files - they don't follow project standards @@ -322,8 +353,9 @@ def main(): for te in type_errors: errors.append(f"TYPE: L{te['line']}: {te['message']}") - # Save type errors to state file for PreToolUse gate - save_diagnostics_state(file_path, type_errors) + # Save ruff lint + type errors to state file for PreToolUse gate (hard block) + ruff_lint_errors = run_ruff_lint_structured(file_path) + save_diagnostics_state(file_path, ruff_lint_errors + type_errors) elif file_path.endswith(".json"): file_type = "JSON" diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/ruff_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/ruff_check.py index f7bf01da..cdcc45c6 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/ruff_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/ruff_check.py @@ -1,20 +1,24 @@ # =================== AIPass ==================== # Name: ruff_check.py # Description: Ruff Linter Standards Checker Handler -# Version: 1.0.0 +# Version: 1.1.0 # Created: 2026-04-16 -# Modified: 2026-04-16 +# Modified: 2026-04-20 # ============================================= """ Ruff Linter Standards Checker Handler -Runs ruff against a branch's apps/ directory and scores based on violation -count. Prevents ruff debt from silently re-accumulating after a cleanup. +Two modes: +- check_branch(): runs ruff across entire apps/ tree (used by audit pipeline, + AUDIT_SCOPE = branch_level, ADVISORY = always-passes) +- check_module(): runs ruff on a single file (used by checklist/per-file hooks, + returns passed=False on violations so subagent_stop_gate can block) -AUDIT_SCOPE: branch_level — runs once per branch, ruff walks the tree. -ADVISORY: surfaces violations and score but always passes overall. - Promote to required once all branches are clean. +AUDIT_SCOPE: branch_level — audit pipeline uses check_branch() once per branch. + Checkers that also implement check_module() are eligible for per-file checklist runs. +ADVISORY: check_branch() surfaces violations but always passes (advisory score). + check_module() returns passed=False so checklist/hooks can block. """ import json @@ -95,6 +99,123 @@ def _score_from_count(count: int) -> int: return 25 +def _find_ruff_bypass_from_file(file_path: str) -> list: + """Walk up from file_path to find .seedgo/ruff_bypass.json at the branch root.""" + fp = Path(file_path).resolve() + for parent in list(fp.parents): + candidate = parent / ".seedgo" / "ruff_bypass.json" + if candidate.exists(): + try: + data = json.loads(candidate.read_text(encoding="utf-8")) + return data if isinstance(data, list) else [] + except Exception as exc: + logger.warning("Failed to load ruff_bypass.json at %s: %s", candidate, exc) + return [] + if (parent / ".git").exists(): + break + return [] + + +def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: + """Run ruff check on a single file. + + Used by checklist mode and subagent_stop_gate for per-file enforcement. + Returns passed=False when violations exist so hooks can block. + + Args: + module_path: Absolute path to the Python file to check. + bypass_rules: Standard bypass rules from .seedgo/bypass.json + + Returns: + dict with passed, checks, score, standard keys. + """ + fp = Path(module_path) + + if is_bypassed(module_path, "ruff_check", bypass_rules=bypass_rules): + return { + "passed": True, + "checks": [{"name": "Ruff check", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}], + "score": 100, + "standard": "RUFF_CHECK", + } + + if shutil.which("ruff") is None: + return { + "passed": True, + "checks": [{"name": "Ruff check", "passed": True, "message": "ruff not installed — check skipped"}], + "score": 100, + "standard": "RUFF_CHECK", + } + + ruff_bypass = _find_ruff_bypass_from_file(module_path) + + try: + proc = subprocess.run( + ["ruff", "check", str(fp), "--output-format=json"], + capture_output=True, + text=True, + timeout=15, + ) + except subprocess.TimeoutExpired: + logger.warning("ruff check_module timed out on %s", module_path) + return { + "passed": True, + "checks": [{"name": "Ruff check", "passed": True, "message": "ruff check timed out — skipped"}], + "score": 100, + "standard": "RUFF_CHECK", + } + except Exception as exc: + logger.warning("ruff check_module failed on %s: %s", module_path, exc) + return { + "passed": True, + "checks": [{"name": "Ruff check", "passed": True, "message": f"ruff error — skipped: {exc}"}], + "score": 100, + "standard": "RUFF_CHECK", + } + + violations: list = [] + if proc.stdout.strip(): + try: + violations = json.loads(proc.stdout) + if not isinstance(violations, list): + violations = [] + except (json.JSONDecodeError, ValueError) as exc: + logger.warning("ruff JSON parse failed for %s: %s", module_path, exc) + violations = [] + + active = [v for v in violations if not _is_ruff_bypassed(v, ruff_bypass)] + count = len(active) + + if count == 0: + json_handler.log_operation( + "check_completed", + {"file": module_path, "score": 100, "standard": "ruff_check"}, + ) + return { + "passed": True, + "checks": [{"name": "Ruff check", "passed": True, "message": "No ruff violations found"}], + "score": 100, + "standard": "RUFF_CHECK", + } + + top = active[:5] + msgs = [f"{v.get('code', '?')} L{v.get('location', {}).get('row', '?')}: {v.get('message', '?')[:80]}" for v in top] + suffix = f" (and {count - 5} more)" if count > 5 else "" + detail = f"{count} violation(s) — " + "; ".join(msgs) + suffix + + json_handler.log_operation( + "check_completed", + {"file": module_path, "score": 0, "standard": "ruff_check", "violations": count}, + ) + + return { + "passed": False, + "checks": [{"name": "Ruff check", "passed": False, "message": detail}], + "score": 0, + "standard": "RUFF_CHECK", + } + + def check_branch(branch_path: str, bypass_rules: list | None = None) -> Dict: """Run ruff against the branch and score based on violation count. diff --git a/src/aipass/seedgo/apps/modules/checklist.py b/src/aipass/seedgo/apps/modules/checklist.py index 67973b0e..807b9e9a 100644 --- a/src/aipass/seedgo/apps/modules/checklist.py +++ b/src/aipass/seedgo/apps/modules/checklist.py @@ -86,13 +86,15 @@ def _is_applicable(checker, file_path: str) -> bool: Rules based on AUDIT_SCOPE: - "entry_point" (default) -> only apps/{name}.py files - "all_files" -> any .py file - - "branch_level" -> not applicable to single-file checks + - "branch_level" -> normally skipped, but eligible if checker + also implements check_module() for per-file use """ scope = getattr(checker, "AUDIT_SCOPE", "entry_point") - # Branch-level checkers need a branch path, not a single file + # Branch-level checkers skip per-file runs UNLESS they also implement + # check_module() for targeted single-file validation (e.g., ruff_check) if scope == "branch_level": - return False + return hasattr(checker, "check_module") and file_path.endswith(".py") # Only check_module() capable checkers if not hasattr(checker, "check_module"):