From 11d35910fd3b14da1a800fb1ae8828aeb079884f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 4 Oct 2026 20:09:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(tooling):=20lint-changed.sh=20=E2=80=94=20?= =?UTF-8?q?fast=20lint=20lanes=20on=20the=20uncommitted=20files,=20usable?= =?UTF-8?q?=20as=20a=20Stop=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/lint-changed.sh | 54 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 scripts/lint-changed.sh diff --git a/scripts/lint-changed.sh b/scripts/lint-changed.sh new file mode 100644 index 00000000..f4feb716 --- /dev/null +++ b/scripts/lint-changed.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# The fast lint lanes on the uncommitted files only — ruff (check + format) on Python, eslint + +# prettier on the frontend — for a check after every edit instead of a full gate run. It is a +# convenience, never the gate: scripts/check.sh stays what a merge or a release requires. +# +# bash scripts/lint-changed.sh lint what `git status` shows as changed or new +# bash scripts/lint-changed.sh --hook the same, as a Claude Code Stop hook: silent and exit 0 when +# clean; otherwise the findings on stderr and exit 2, which +# hands them back to the agent before it ends its turn +# +# Measured 2026-10-04: one cold eslint run (type-aware) takes 3.4–4.7 s, so this runs once per turn +# (Stop), not after every edit — a warm daemon (eslint_d) would add a dependency and a stray process. +set -u +cd "$(dirname "$0")/.." || exit 2 +hook=0 +[ "${1:-}" = --hook ] && hook=1 + +if [ "$hook" = 1 ]; then + # A Stop hook that blocked once is told so on the next stop; let that one through, or an unfixable + # finding would keep the agent in a loop. + grep -q '"stop_hook_active": *true' && exit 0 +fi + +mapfile -t changed < <(git status --porcelain --untracked-files=all | sed -E 's/^.. //; s/.* -> //' | + while read -r f; do [ -f "$f" ] && printf '%s\n' "$f"; done) +py=() fe=() +for f in "${changed[@]}"; do + case "$f" in + backend/*.py | scripts/*.py) py+=("$f") ;; + frontend/*.ts | frontend/*.tsx | frontend/*.js | frontend/*.mjs) fe+=("${f#frontend/}") ;; + esac +done + +report="$( + if [ ${#py[@]} -gt 0 ]; then + python -m ruff check --quiet --config backend/ruff.toml "${py[@]}" 2>&1 + python -m ruff format --check --quiet --config backend/ruff.toml "${py[@]}" 2>&1 + fi + if [ ${#fe[@]} -gt 0 ]; then + (cd frontend && npx eslint --max-warnings 0 "${fe[@]}" 2>&1) + (cd frontend && npx prettier --check --log-level warn "${fe[@]}" 2>&1) + fi +)" + +if [ -z "$report" ]; then + [ "$hook" = 1 ] || echo "lint-changed: clean (${#py[@]} py, ${#fe[@]} frontend)" + exit 0 +fi +if [ "$hook" = 1 ]; then + printf 'lint-changed found problems in the uncommitted files:\n%s\n' "$report" >&2 + exit 2 +fi +printf '%s\n' "$report" +exit 1