Merge pull request #370 from AIOSAI/citizen/seedgo-ruff-checklist-gap

feat(seedgo+hooks): close ruff F401 checklist gap — hard-block lint in pre-edit gate
This commit is contained in:
AIPass
2026-04-21 08:27:38 -07:00
committed by GitHub
3 changed files with 173 additions and 18 deletions
+40 -8
View File
@@ -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"
@@ -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.
+5 -3
View File
@@ -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"):