468 lines
17 KiB
Python
468 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Post-edit hook that detects stub patterns, runs linters, and reminds about tests.
|
|
Runs after Write/Edit tool usage.
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import glob
|
|
import time
|
|
|
|
# Add hooks directory to path for shared module import
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from crosslink_config import find_crosslink_dir, is_agent_context
|
|
|
|
# Stub patterns to detect (compiled regex for performance)
|
|
STUB_PATTERNS = [
|
|
(r'\bTODO\b', 'TODO comment'),
|
|
(r'\bFIXME\b', 'FIXME comment'),
|
|
(r'\bXXX\b', 'XXX marker'),
|
|
(r'\bHACK\b', 'HACK marker'),
|
|
(r'^\s*pass\s*$', 'bare pass statement'),
|
|
(r'^\s*\.\.\.\s*$', 'ellipsis placeholder'),
|
|
(r'\bunimplemented!\s*\(\s*\)', 'unimplemented!() macro'),
|
|
(r'\btodo!\s*\(\s*\)', 'todo!() macro'),
|
|
(r'\bpanic!\s*\(\s*"not implemented', 'panic not implemented'),
|
|
(r'raise\s+NotImplementedError\s*\(\s*\)', 'bare NotImplementedError'),
|
|
(r'#\s*implement\s*(later|this|here)', 'implement later comment'),
|
|
(r'//\s*implement\s*(later|this|here)', 'implement later comment'),
|
|
(r'def\s+\w+\s*\([^)]*\)\s*:\s*(pass|\.\.\.)\s*$', 'empty function'),
|
|
(r'fn\s+\w+\s*\([^)]*\)\s*\{\s*\}', 'empty function body'),
|
|
(r'return\s+None\s*#.*stub', 'stub return'),
|
|
]
|
|
|
|
COMPILED_PATTERNS = [(re.compile(p, re.IGNORECASE | re.MULTILINE), desc) for p, desc in STUB_PATTERNS]
|
|
|
|
|
|
def check_for_stubs(file_path):
|
|
"""Check file for stub patterns. Returns list of (line_num, pattern_desc, line_content)."""
|
|
if not os.path.exists(file_path):
|
|
return []
|
|
|
|
try:
|
|
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
content = f.read()
|
|
lines = content.split('\n')
|
|
except (OSError, Exception):
|
|
return []
|
|
|
|
findings = []
|
|
for line_num, line in enumerate(lines, 1):
|
|
for pattern, desc in COMPILED_PATTERNS:
|
|
if pattern.search(line):
|
|
if 'NotImplementedError' in line and re.search(r'NotImplementedError\s*\(\s*["\'][^"\']+["\']', line):
|
|
continue
|
|
findings.append((line_num, desc, line.strip()[:60]))
|
|
|
|
return findings
|
|
|
|
|
|
def find_project_root(file_path, marker_files):
|
|
"""Walk up from file_path looking for project root markers."""
|
|
current = os.path.dirname(os.path.abspath(file_path))
|
|
for _ in range(10): # Max 10 levels up
|
|
for marker in marker_files:
|
|
if os.path.exists(os.path.join(current, marker)):
|
|
return current
|
|
parent = os.path.dirname(current)
|
|
if parent == current:
|
|
break
|
|
current = parent
|
|
return None
|
|
|
|
|
|
def run_linter(file_path, max_errors=10):
|
|
"""Run appropriate linter and return first N errors."""
|
|
ext = os.path.splitext(file_path)[1].lower()
|
|
errors = []
|
|
|
|
try:
|
|
if ext == '.rs':
|
|
# Rust: run cargo clippy from project root
|
|
project_root = find_project_root(file_path, ['Cargo.toml'])
|
|
if project_root:
|
|
result = subprocess.run(
|
|
['cargo', 'clippy', '--message-format=short', '--quiet'],
|
|
cwd=project_root,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30
|
|
)
|
|
if result.stderr:
|
|
for line in result.stderr.split('\n'):
|
|
if line.strip() and ('error' in line.lower() or 'warning' in line.lower()):
|
|
errors.append(line.strip()[:100])
|
|
if len(errors) >= max_errors:
|
|
break
|
|
|
|
elif ext == '.py':
|
|
# Python: try flake8, fall back to py_compile
|
|
try:
|
|
result = subprocess.run(
|
|
['flake8', '--max-line-length=120', file_path],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10
|
|
)
|
|
for line in result.stdout.split('\n'):
|
|
if line.strip():
|
|
errors.append(line.strip()[:100])
|
|
if len(errors) >= max_errors:
|
|
break
|
|
except FileNotFoundError:
|
|
# flake8 not installed, try py_compile
|
|
result = subprocess.run(
|
|
['python', '-m', 'py_compile', file_path],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10
|
|
)
|
|
if result.stderr:
|
|
errors.append(result.stderr.strip()[:200])
|
|
|
|
elif ext in ('.js', '.ts', '.tsx', '.jsx'):
|
|
# JavaScript/TypeScript: try eslint
|
|
project_root = find_project_root(file_path, ['package.json', '.eslintrc', '.eslintrc.js', '.eslintrc.json'])
|
|
if project_root:
|
|
try:
|
|
result = subprocess.run(
|
|
['npx', 'eslint', '--format=compact', file_path],
|
|
cwd=project_root,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30
|
|
)
|
|
for line in result.stdout.split('\n'):
|
|
if line.strip() and (':' in line):
|
|
errors.append(line.strip()[:100])
|
|
if len(errors) >= max_errors:
|
|
break
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
elif ext == '.go':
|
|
# Go: run go vet
|
|
project_root = find_project_root(file_path, ['go.mod'])
|
|
if project_root:
|
|
result = subprocess.run(
|
|
['go', 'vet', './...'],
|
|
cwd=project_root,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30
|
|
)
|
|
if result.stderr:
|
|
for line in result.stderr.split('\n'):
|
|
if line.strip():
|
|
errors.append(line.strip()[:100])
|
|
if len(errors) >= max_errors:
|
|
break
|
|
|
|
elif ext in ('.ex', '.exs', '.heex'):
|
|
# Elixir: run mix format --check-formatted, then mix credo --strict if available
|
|
project_root = find_project_root(file_path, ['mix.exs'])
|
|
if project_root:
|
|
# mix format --check-formatted on the specific file
|
|
result = subprocess.run(
|
|
['mix', 'format', '--check-formatted', file_path],
|
|
cwd=project_root,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30
|
|
)
|
|
if result.returncode != 0:
|
|
for line in result.stderr.split('\n'):
|
|
if line.strip():
|
|
errors.append(line.strip()[:100])
|
|
if len(errors) >= max_errors:
|
|
break
|
|
|
|
# Run mix credo --strict only if credo is in deps
|
|
if len(errors) < max_errors:
|
|
mix_exs_path = os.path.join(project_root, 'mix.exs')
|
|
has_credo = False
|
|
try:
|
|
with open(mix_exs_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
if ':credo' in f.read():
|
|
has_credo = True
|
|
except OSError:
|
|
pass
|
|
|
|
if has_credo:
|
|
result = subprocess.run(
|
|
['mix', 'credo', '--strict', '--format', 'oneline', file_path],
|
|
cwd=project_root,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30
|
|
)
|
|
if result.stdout:
|
|
for line in result.stdout.split('\n'):
|
|
if line.strip() and ':' in line:
|
|
errors.append(line.strip()[:100])
|
|
if len(errors) >= max_errors:
|
|
break
|
|
|
|
except subprocess.TimeoutExpired:
|
|
errors.append("(linter timed out)")
|
|
except (OSError, Exception) as e:
|
|
pass # Linter not available, skip silently
|
|
|
|
return errors
|
|
|
|
|
|
def is_test_file(file_path):
|
|
"""Check if file is a test file."""
|
|
basename = os.path.basename(file_path).lower()
|
|
dirname = os.path.dirname(file_path).lower()
|
|
|
|
# Common test file patterns
|
|
test_patterns = [
|
|
'test_', '_test.', '.test.', 'spec.', '_spec.',
|
|
'tests.', 'testing.', 'mock.', '_mock.', '_test.exs'
|
|
]
|
|
# Common test directories
|
|
test_dirs = ['test', 'tests', '__tests__', 'spec', 'specs', 'testing']
|
|
|
|
for pattern in test_patterns:
|
|
if pattern in basename:
|
|
return True
|
|
|
|
for test_dir in test_dirs:
|
|
if test_dir in dirname.split(os.sep):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def find_test_files(file_path, project_root):
|
|
"""Find test files related to source file."""
|
|
if not project_root:
|
|
return []
|
|
|
|
ext = os.path.splitext(file_path)[1]
|
|
basename = os.path.basename(file_path)
|
|
name_without_ext = os.path.splitext(basename)[0]
|
|
|
|
# Patterns to look for
|
|
test_patterns = []
|
|
|
|
if ext == '.rs':
|
|
# Rust: look for mod tests in same file, or tests/ directory
|
|
test_patterns = [
|
|
os.path.join(project_root, 'tests', '**', f'*{name_without_ext}*'),
|
|
os.path.join(project_root, '**', 'tests', f'*{name_without_ext}*'),
|
|
]
|
|
elif ext == '.py':
|
|
test_patterns = [
|
|
os.path.join(project_root, '**', f'test_{name_without_ext}.py'),
|
|
os.path.join(project_root, '**', f'{name_without_ext}_test.py'),
|
|
os.path.join(project_root, 'tests', '**', f'*{name_without_ext}*.py'),
|
|
]
|
|
elif ext in ('.js', '.ts', '.tsx', '.jsx'):
|
|
base = name_without_ext.replace('.test', '').replace('.spec', '')
|
|
test_patterns = [
|
|
os.path.join(project_root, '**', f'{base}.test{ext}'),
|
|
os.path.join(project_root, '**', f'{base}.spec{ext}'),
|
|
os.path.join(project_root, '**', '__tests__', f'{base}*'),
|
|
]
|
|
elif ext == '.go':
|
|
test_patterns = [
|
|
os.path.join(os.path.dirname(file_path), f'{name_without_ext}_test.go'),
|
|
]
|
|
elif ext in ('.ex', '.exs'):
|
|
test_patterns = [
|
|
os.path.join(project_root, 'test', '**', f'{name_without_ext}_test.exs'),
|
|
os.path.join(project_root, 'test', '**', f'*{name_without_ext}*_test.exs'),
|
|
]
|
|
|
|
found = []
|
|
for pattern in test_patterns:
|
|
found.extend(glob.glob(pattern, recursive=True))
|
|
|
|
return list(set(found))[:5] # Limit to 5
|
|
|
|
|
|
def get_test_reminder(file_path, project_root):
|
|
"""Check if tests should be run and return reminder message."""
|
|
if is_test_file(file_path):
|
|
return None # Editing a test file, no reminder needed
|
|
|
|
ext = os.path.splitext(file_path)[1]
|
|
code_extensions = ('.rs', '.py', '.js', '.ts', '.tsx', '.jsx', '.go', '.ex', '.exs', '.heex')
|
|
|
|
if ext not in code_extensions:
|
|
return None
|
|
|
|
# Check for marker file
|
|
marker_dir = project_root or os.path.dirname(file_path)
|
|
marker_file = os.path.join(marker_dir, '.crosslink', 'last_test_run')
|
|
|
|
code_modified_after_tests = False
|
|
|
|
if os.path.exists(marker_file):
|
|
try:
|
|
marker_mtime = os.path.getmtime(marker_file)
|
|
file_mtime = os.path.getmtime(file_path)
|
|
code_modified_after_tests = file_mtime > marker_mtime
|
|
except OSError:
|
|
code_modified_after_tests = True
|
|
else:
|
|
# No marker = tests haven't been run
|
|
code_modified_after_tests = True
|
|
|
|
if not code_modified_after_tests:
|
|
return None
|
|
|
|
# Find test files
|
|
test_files = find_test_files(file_path, project_root)
|
|
|
|
# Generate test command based on project type
|
|
test_cmd = None
|
|
if ext == '.rs' and project_root:
|
|
if os.path.exists(os.path.join(project_root, 'Cargo.toml')):
|
|
test_cmd = 'cargo test'
|
|
elif ext == '.py':
|
|
if project_root and os.path.exists(os.path.join(project_root, 'pytest.ini')):
|
|
test_cmd = 'pytest'
|
|
elif project_root and os.path.exists(os.path.join(project_root, 'setup.py')):
|
|
test_cmd = 'python -m pytest'
|
|
elif ext in ('.js', '.ts', '.tsx', '.jsx') and project_root:
|
|
if os.path.exists(os.path.join(project_root, 'package.json')):
|
|
test_cmd = 'npm test'
|
|
elif ext == '.go' and project_root:
|
|
test_cmd = 'go test ./...'
|
|
elif ext in ('.ex', '.exs', '.heex') and project_root:
|
|
if os.path.exists(os.path.join(project_root, 'mix.exs')):
|
|
test_cmd = 'mix test'
|
|
|
|
if test_files or test_cmd:
|
|
msg = "🧪 TEST REMINDER: Code modified since last test run."
|
|
if test_cmd:
|
|
msg += f"\n Run: {test_cmd}"
|
|
if test_files:
|
|
msg += f"\n Related tests: {', '.join(os.path.basename(t) for t in test_files[:3])}"
|
|
return msg
|
|
|
|
return None
|
|
|
|
|
|
def main():
|
|
try:
|
|
input_data = json.load(sys.stdin)
|
|
except (json.JSONDecodeError, Exception):
|
|
sys.exit(0)
|
|
|
|
tool_name = input_data.get("tool_name", "")
|
|
tool_input = input_data.get("tool_input", {})
|
|
|
|
if tool_name not in ("Write", "Edit"):
|
|
sys.exit(0)
|
|
|
|
file_path = tool_input.get("file_path", "")
|
|
|
|
code_extensions = (
|
|
'.rs', '.py', '.js', '.ts', '.tsx', '.jsx', '.go', '.java',
|
|
'.c', '.cpp', '.h', '.hpp', '.cs', '.rb', '.php', '.swift',
|
|
'.kt', '.scala', '.zig', '.odin', '.ex', '.exs', '.heex'
|
|
)
|
|
|
|
if not any(file_path.endswith(ext) for ext in code_extensions):
|
|
sys.exit(0)
|
|
|
|
if '.claude' in file_path and 'hooks' in file_path:
|
|
sys.exit(0)
|
|
|
|
# Find project root for linter and test detection
|
|
project_root = find_project_root(file_path, [
|
|
'Cargo.toml', 'package.json', 'go.mod', 'setup.py',
|
|
'pyproject.toml', 'mix.exs', '.git'
|
|
])
|
|
|
|
# Detect agent context — agents skip linting and test reminders
|
|
# (they run their own CI checks), but stub detection stays active
|
|
crosslink_dir = find_crosslink_dir()
|
|
is_agent = is_agent_context(crosslink_dir)
|
|
|
|
# Check for stubs (always - instant regex check, even for agents)
|
|
stub_findings = check_for_stubs(file_path)
|
|
|
|
# Skip linting and test reminders for agents (too slow, agents have CI)
|
|
linter_errors = []
|
|
test_reminder = None
|
|
|
|
if not is_agent:
|
|
# Debounced linting: only run linter if no edits in last 10 seconds
|
|
lint_marker = None
|
|
if project_root:
|
|
crosslink_cache = os.path.join(project_root, '.crosslink', '.cache')
|
|
lint_marker = os.path.join(crosslink_cache, 'last-edit-time')
|
|
|
|
should_lint = True
|
|
if lint_marker:
|
|
try:
|
|
os.makedirs(os.path.dirname(lint_marker), exist_ok=True)
|
|
if os.path.exists(lint_marker):
|
|
last_edit = os.path.getmtime(lint_marker)
|
|
elapsed = time.time() - last_edit
|
|
if elapsed < 10:
|
|
should_lint = False
|
|
with open(lint_marker, 'w') as f:
|
|
f.write(str(time.time()))
|
|
except OSError:
|
|
pass
|
|
|
|
if should_lint:
|
|
linter_errors = run_linter(file_path)
|
|
|
|
# Check for test reminder
|
|
test_reminder = get_test_reminder(file_path, project_root)
|
|
|
|
# Build output
|
|
messages = []
|
|
|
|
if stub_findings:
|
|
stub_list = "\n".join([f" Line {ln}: {desc} - `{content}`" for ln, desc, content in stub_findings[:5]])
|
|
if len(stub_findings) > 5:
|
|
stub_list += f"\n ... and {len(stub_findings) - 5} more"
|
|
messages.append(f"""⚠️ STUB PATTERNS DETECTED in {file_path}:
|
|
{stub_list}
|
|
|
|
Fix these NOW - replace with real implementation.""")
|
|
|
|
if linter_errors:
|
|
error_list = "\n".join([f" {e}" for e in linter_errors[:10]])
|
|
if len(linter_errors) > 10:
|
|
error_list += f"\n ... and more"
|
|
messages.append(f"""🔍 LINTER ISSUES:
|
|
{error_list}""")
|
|
|
|
if test_reminder:
|
|
messages.append(test_reminder)
|
|
|
|
if messages:
|
|
output = {
|
|
"hookSpecificOutput": {
|
|
"hookEventName": "PostToolUse",
|
|
"additionalContext": "\n\n".join(messages)
|
|
}
|
|
}
|
|
else:
|
|
output = {
|
|
"hookSpecificOutput": {
|
|
"hookEventName": "PostToolUse",
|
|
"additionalContext": f"✓ {os.path.basename(file_path)} - no issues detected"
|
|
}
|
|
}
|
|
|
|
print(json.dumps(output))
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|