feat(seedgo+hooks): close ruff F401 checklist gap — hard-block lint errors in pre-edit gate
ruff_check was branch_level only so drone @seedgo checklist <file> skipped it entirely. F401 unused imports slipped through to main twice (PRs #349, #357) because neither checklist nor the pre-edit gate caught them per-file. Changes: - ruff_check.py v1.1.0: add check_module() for single-file ruff runs + _find_ruff_bypass_from_file() to locate .seedgo/ruff_bypass.json from any file path. AUDIT_SCOPE stays branch_level (audit pipeline unchanged). - checklist.py: update _is_applicable() — branch_level checkers that also implement check_module() are now eligible for per-file checklist runs. ruff_check gains per-file enforcement; dead_code/test_quality/unused_function remain skipped (genuinely need full branch context). - auto_fix_diagnostics.py v5.2.0: add run_ruff_lint_structured() — runs ruff --output-format=json and saves violations as {line, message} dicts alongside pyright errors in the state file. Pre-edit gate now hard-blocks on F401/lint just like type errors. - pre_edit_gate.py v1.2.0: generalize reason message from "type error(s)" to "error(s)" since state now contains lint violations too. Verification: drone @seedgo checklist <F401 file> now shows ✗ ruff: 2 violation(s) — F401 L1/L2. 333 seedgo tests pass.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user