Add composable multi-language linting and formatting system
PostToolUse hook auto-formats files after Edit/Write/MultiEdit with git-blob checkpoints for safe revert. Pre-commit hook for staged files. Canonical formatter scripts for py, sh, ts, sql, json (+ symlinks for js, yaml, md). Install and setup scripts for project opt-in. Includes best-practices/linting.md, HOOKS.md docs, README.md, and session log. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
86
hooks/post-edit-lint.sh
Executable file
86
hooks/post-edit-lint.sh
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
# PostToolUse hook: format and lint edited/written files.
|
||||
# Dispatches to project-local formatters/ directory (symlinks to claude-foundations/formatters/).
|
||||
#
|
||||
# Input: JSON on stdin with tool_name, file_path, cwd fields
|
||||
# Exit 0: silent pass (no formatter, clean lint, or missing tools)
|
||||
# Exit 2: lint errors remain — stderr fed back to Claude as feedback
|
||||
|
||||
# Parse stdin JSON for file_path and cwd
|
||||
INPUT="$(cat)"
|
||||
FILE_PATH="$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('file_path',''))" 2>/dev/null)"
|
||||
CWD="$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('cwd',''))" 2>/dev/null)"
|
||||
|
||||
# No file path — nothing to do
|
||||
[ -z "$FILE_PATH" ] && exit 0
|
||||
|
||||
# Make path absolute if relative
|
||||
if [[ "$FILE_PATH" != /* ]]; then
|
||||
FILE_PATH="${CWD:-.}/$FILE_PATH"
|
||||
fi
|
||||
|
||||
# File must exist
|
||||
[ -f "$FILE_PATH" ] || exit 0
|
||||
|
||||
# Extract extension (normalise yml → yaml)
|
||||
EXT="${FILE_PATH##*.}"
|
||||
case "$EXT" in
|
||||
yml) EXT="yaml" ;;
|
||||
esac
|
||||
|
||||
# No extension — nothing to do
|
||||
[ -z "$EXT" ] || [ "$EXT" = "$FILE_PATH" ] && exit 0
|
||||
|
||||
# Find the project's formatters/ directory by walking up from cwd
|
||||
find_formatters_dir() {
|
||||
local dir="${1:-$CWD}"
|
||||
while [ "$dir" != "/" ] && [ -n "$dir" ]; do
|
||||
if [ -d "$dir/formatters" ]; then
|
||||
echo "$dir/formatters"
|
||||
return 0
|
||||
fi
|
||||
dir="$(dirname "$dir")"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
FORMATTERS_DIR="$(find_formatters_dir "$CWD")"
|
||||
[ -z "$FORMATTERS_DIR" ] && exit 0
|
||||
|
||||
# Check if a formatter exists for this extension
|
||||
FORMATTER="$FORMATTERS_DIR/$EXT"
|
||||
[ -x "$FORMATTER" ] || exit 0
|
||||
|
||||
# --- Checkpoint: save pre-format content ---
|
||||
if git -C "$(dirname "$FILE_PATH")" rev-parse --git-dir >/dev/null 2>&1; then
|
||||
# Inside a git repo — use git hash-object for efficient checkpoint
|
||||
BLOB="$(git -C "$(dirname "$FILE_PATH")" hash-object -w "$FILE_PATH" 2>/dev/null)"
|
||||
if [ -n "$BLOB" ]; then
|
||||
echo "$BLOB" > "${FILE_PATH}.pre-lint"
|
||||
fi
|
||||
else
|
||||
# Not a git repo — fall back to file copy
|
||||
cp "$FILE_PATH" "${FILE_PATH}.pre-lint"
|
||||
fi
|
||||
|
||||
# --- Run formatter ---
|
||||
LINT_OUTPUT=$("$FORMATTER" "$FILE_PATH" 2>&1)
|
||||
FORMATTER_EXIT=$?
|
||||
|
||||
if [ $FORMATTER_EXIT -eq 0 ]; then
|
||||
# Clean — remove checkpoint
|
||||
rm -f "${FILE_PATH}.pre-lint"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Lint errors remain ---
|
||||
# Keep .pre-lint for revert, exit 2 to feed errors back to Claude
|
||||
{
|
||||
echo "Lint errors in ${FILE_PATH}:"
|
||||
echo "$LINT_OUTPUT"
|
||||
echo ""
|
||||
echo "To fix: use the Agent tool to resolve these lint errors (keeps fix loop out of main context)."
|
||||
echo "To revert formatting: git cat-file blob \$(cat ${FILE_PATH}.pre-lint) > ${FILE_PATH}"
|
||||
} >&2
|
||||
|
||||
exit 2
|
||||
54
hooks/pre-commit-lint.sh
Executable file
54
hooks/pre-commit-lint.sh
Executable file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Git pre-commit hook: format and lint staged files using project formatters.
|
||||
# Reuses the same formatter scripts from formatters/.
|
||||
# Exits non-zero if lint errors remain after auto-fix (blocks commit).
|
||||
|
||||
# Find formatters/ directory relative to repo root
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
|
||||
[ -z "$REPO_ROOT" ] && exit 0
|
||||
|
||||
FORMATTERS_DIR="$REPO_ROOT/formatters"
|
||||
[ -d "$FORMATTERS_DIR" ] || exit 0
|
||||
|
||||
HAD_ERRORS=0
|
||||
ERROR_OUTPUT=""
|
||||
|
||||
# Iterate staged files (only Added/Modified, not Deleted)
|
||||
while IFS= read -r FILE; do
|
||||
[ -z "$FILE" ] && continue
|
||||
|
||||
ABS_FILE="$REPO_ROOT/$FILE"
|
||||
[ -f "$ABS_FILE" ] || continue
|
||||
|
||||
# Extract extension (normalise yml → yaml)
|
||||
EXT="${FILE##*.}"
|
||||
case "$EXT" in
|
||||
yml) EXT="yaml" ;;
|
||||
esac
|
||||
[ -z "$EXT" ] || [ "$EXT" = "$FILE" ] && continue
|
||||
|
||||
# Check for formatter
|
||||
FORMATTER="$FORMATTERS_DIR/$EXT"
|
||||
[ -x "$FORMATTER" ] || continue
|
||||
|
||||
# Run formatter
|
||||
ERRORS=$("$FORMATTER" "$ABS_FILE" 2>&1)
|
||||
EXIT_CODE=$?
|
||||
|
||||
# Re-stage the file (formatter may have modified it)
|
||||
git add "$ABS_FILE"
|
||||
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
HAD_ERRORS=1
|
||||
ERROR_OUTPUT="${ERROR_OUTPUT}${FILE}:\n${ERRORS}\n\n"
|
||||
fi
|
||||
done < <(git diff --cached --name-only --diff-filter=AM)
|
||||
|
||||
if [ $HAD_ERRORS -ne 0 ]; then
|
||||
echo "Pre-commit lint errors — commit blocked:" >&2
|
||||
echo -e "$ERROR_OUTPUT" >&2
|
||||
echo "Fix the errors above and try again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Reference in New Issue
Block a user