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/.claude/hooks/pre_edit_gate.py b/.claude/hooks/pre_edit_gate.py new file mode 100644 index 00000000..503b370b --- /dev/null +++ b/.claude/hooks/pre_edit_gate.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +PreToolUse Gate — Blocks edits when unresolved type errors exist. + +Two-hook system: + PostToolUse (auto_fix_diagnostics.py) → detects errors, saves to state file + PreToolUse (this file) → reads state file, blocks edits to OTHER files + +Logic: + - No state file or empty → ALLOW + - Editing the SAME file that has errors → ALLOW (they're fixing it) + - Errored file in a DIFFERENT branch → ALLOW (not your problem) + - Editing a DIFFERENT file in SAME branch → BLOCK (fix errors first) + +Version: 1.2.0 +""" + +import json +import sys +from pathlib import Path + +STATE_FILE = Path(__file__).parent / ".diagnostics_state.json" +EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"} + + +def _get_branch(file_path: str) -> str: + """Extract AIPass branch name from file path. + + Looks for src/aipass/{branch}/ pattern. Returns branch name + or empty string if not in a branch. + """ + parts = Path(file_path).parts + for i, part in enumerate(parts): + if part == "aipass" and i > 0 and parts[i - 1] == "src" and i + 1 < len(parts): + return parts[i + 1] + return "" + + +def main(): + try: + input_data = json.load(sys.stdin) + tool_name = input_data.get("tool_name", "") + tool_input = input_data.get("tool_input", {}) + file_path = tool_input.get("file_path", "") + + # Only gate edit tools + if tool_name not in EDIT_TOOLS: + return + + # Only gate Python files + if not file_path.endswith(".py"): + return + + # No state file → no pending errors → allow + if not STATE_FILE.exists(): + return + + try: + state = json.loads(STATE_FILE.read_text(encoding="utf-8")) + except (json.JSONDecodeError, IOError): + return # Corrupted state → allow + + errored_file = state.get("file", "") + errors = state.get("errors", []) + + # No errors in state → allow + if not errors: + return + + # Resolve both paths for comparison + try: + current = str(Path(file_path).resolve()) + errored = str(Path(errored_file).resolve()) + except (OSError, ValueError): + return # Path resolution failed → allow + + # Editing the file WITH errors → allow (they're fixing it) + if current == errored: + return + + # Different branch → allow (cross-branch errors aren't your problem) + # If errored file is outside AIPass entirely → allow (external projects) + current_branch = _get_branch(current) + errored_branch = _get_branch(errored) + if not errored_branch: + return # Errored file is outside src/aipass/ — don't gate + if current_branch and errored_branch and current_branch != errored_branch: + return + + # Editing a DIFFERENT file in SAME branch while errors exist → BLOCK + error_summary = "\n".join(f" L{e['line']}: {e['message']}" for e in errors[:5]) + reason = f"Fix {len(errors)} error(s) in {Path(errored_file).name} before editing other files:\n{error_summary}" + + output = { + "decision": "block", + "reason": reason + } + print(json.dumps(output)) + sys.exit(2) + + except Exception: + pass # Silent fail → allow + + +if __name__ == "__main__": + main() 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"):