Split lint_python.sh: output helpers and the full-mode linters

353 -> 175 + 116 + 96. The print_* helpers, run_tool, check_tool and the
--help text move to lib/lint_output.sh; everything that runs after
--quick would have exited moves to lib/lint_full_tools.sh as
run_full_mode_linters. The output lib is sourced before argument parsing
because --help now calls usage() from it.

Verified on the path that actually contains the change: a full-mode run
diffed byte-identical against a detached worktree at the pre-split
commit, with pylint, flake8 and pycodestyle in the output proving the run
entered the extracted block. The --quick comparison is also identical but
exits before that block, so it alone would have proved little. The
failure path returns 1 on an unformatted file, confirming OVERALL_STATUS
still propagates out of the function.

Two shellcheck findings fixed rather than suppressed: SC2034 on
OVERALL_STATUS (the lib is analysed standalone, so the cross-seam
variables are now declared with a comment), and a pre-existing unquoted
`exit ${OVERALL_STATUS}` that the commit hook's stricter settings
surfaced. lint_python.sh was shfmt-clean before and still is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
Krzysztof Rudnicki 2026-08-16 23:08:02 +02:00
parent 4ff7a0bf15
commit 97a722d2b2
5 changed files with 295 additions and 187 deletions

View File

@ -0,0 +1,19 @@
{
"title": "Split meta/lint_python.sh under the 250-line cap",
"objective": "Bring meta/lint_python.sh (353 lines) under the repo-wide 250-line cap by moving its output helpers and its full-mode linter block into two sourced libs, with the entry script keeping configuration, argument parsing, the quick-mode tools and the summary.",
"acceptance_criteria": [
"bash ~/utils/scripts/check_file_length.sh reports no violation for meta/lint_python.sh or either new lib.",
"A full-mode run (no --quick) produces byte-identical output to the same run at the pre-split commit, with paths normalised -- and the run demonstrably enters the extracted block, evidenced by pylint, flake8 and pycodestyle appearing in the output.",
"A --quick run produces identical output and exit 0.",
"The failure path still exits 1: a deliberately unformatted Python file makes the script report failing tools and return non-zero, proving OVERALL_STATUS propagates out of the extracted function.",
"shellcheck -x on the entry and shellcheck on both libs exit 0 with no suppression added.",
"meta/lint_python.sh was shfmt-clean before the change and remains shfmt-clean after it."
],
"out_of_scope": [
"Adding, removing or reconfiguring any linter.",
"Changing flag semantics or the default target paths.",
"Reformatting the entry script's existing style.",
"Installing the optional linters (bandit, vulture, pydocstyle, radon, interrogate, pyright) that are absent on this machine."
],
"verifier": "bash meta/lint_python.sh scripts/check_requirements_sync.py (expects exit 0, 'All linting checks passed!', with pylint/flake8/pycodestyle among the passing tools), diffed against the same command run in a detached worktree at the pre-split commit; plus bash meta/lint_python.sh on an unformatted file expecting exit 1; plus shellcheck, shfmt -d and check_file_length.sh on all three files."
}

View File

@ -0,0 +1,49 @@
{
"intent": "Bring meta/lint_python.sh (353 lines) under the 250-line cap by extracting the output helpers and the full-mode linter block into sourced libs.",
"scope": [
"meta/lint_python.sh",
"meta/lib/lint_output.sh (new)",
"meta/lib/lint_full_tools.sh (new)",
"Non-goal: no linter added, removed or reconfigured; no flag behaviour changed"
],
"changes": [
"Move print_header/print_subheader/print_success/print_warning/print_error/print_info/run_tool/check_tool into lib/lint_output.sh, plus the --help text as usage().",
"Move the post-quick-mode linters (pylint, bandit, vulture, flake8, pycodestyle, pydocstyle, radon, interrogate, pyright, autoflake, pyupgrade, codespell) into lib/lint_full_tools.sh as run_full_mode_linters().",
"Source lint_output.sh immediately after SCRIPT_DIR, before argument parsing, because --help now calls usage() from it."
],
"verification": [
{
"command": "bash meta/lint_python.sh --quick scripts/check_requirements_sync.py, before and after, full output diffed",
"result": "pass",
"evidence": "exit 0 both times, output identical. Note this path exits before the extracted full-mode block, so on its own it does NOT cover the biggest part of the change."
},
{
"command": "bash meta/lint_python.sh scripts/check_requirements_sync.py (full mode), against a detached worktree at the pre-split commit, paths normalised",
"result": "pass",
"evidence": "FULL OUTPUT IDENTICAL. Six tools ran and passed -- ruff-lint, ruff-format-check, mypy, pylint, flake8, pycodestyle -- so the run genuinely entered run_full_mode_linters rather than skipping it."
},
{
"command": "bash meta/lint_python.sh <a deliberately unformatted .py> (failure path)",
"result": "pass",
"evidence": "exit 1, with ruff-format-check, pylint, flake8 and pycodestyle all reporting issues. Confirms OVERALL_STATUS still propagates out of the extracted function; the temp file was removed afterwards."
},
{
"command": "bash -n on all three; shellcheck -x on the entry; shellcheck on both libs; shfmt -d on all three",
"result": "pass",
"evidence": "All exit 0. SC2034 (OVERALL_STATUS appears unused, because shellcheck analyses the lib standalone) was fixed by declaring the cross-seam variables at the top of the lib with a comment, not suppressed. lint_python.sh was shfmt-clean at HEAD and is still clean; the one dirty new lib was formatted, banner count 2 before and after, and the full run re-diffed identical afterwards."
},
{
"command": "bash ~/utils/scripts/check_file_length.sh on all three",
"result": "pass",
"evidence": "353 -> 175 (entry) + 116 (full tools) + 96 (output helpers), all under 250."
}
],
"risks": [
"Both libs depend on caller state (TARGET_FILES, FIX_MODE, REPORT_MODE, PROJECT_ROOT, FAILED_TOOLS, OVERALL_STATUS and the colour variables). Documented in each header; the full-mode lib also declares them so the dependency is visible to shellcheck.",
"Optional linters (bandit, vulture, pydocstyle, radon, interrogate, pyright) are not installed here, so their check_tool branches were skipped in both baseline and post-split runs. The identical-output comparison therefore does not exercise them -- but it does not exercise them before the split either, and the blocks moved verbatim."
],
"rollback": [
"git revert the commit; both libs disappear and lint_python.sh returns to 353 lines.",
"Run `bash meta/lint_python.sh scripts/check_requirements_sync.py` and confirm exit 0 with 'All linting checks passed!'."
]
}

123
meta/lib/lint_full_tools.sh Executable file
View File

@ -0,0 +1,123 @@
#!/bin/bash
# ==============================================================================
# The full-mode linters: everything that runs after --quick would have exited.
#
# Split out of lint_python.sh for the 250-line cap. Each block is guarded by
# check_tool, so a missing linter is skipped rather than fatal. Reads
# TARGET_FILES / FIX_MODE and sets OVERALL_STATUS in the caller's scope.
# ==============================================================================
# shellcheck shell=bash
# Declared here so shellcheck can see that these cross the seam: the caller
# owns them, this file reads TARGET_FILES/FIX_MODE/REPORT_MODE and writes
# OVERALL_STATUS back. Without the declaration shellcheck analyses this file
# standalone and reports OVERALL_STATUS as unused (SC2034).
: "${TARGET_FILES:=}" "${FIX_MODE:=false}" "${REPORT_MODE:=false}"
OVERALL_STATUS="${OVERALL_STATUS:-0}"
run_full_mode_linters() {
# ==============================================================================
# PYLINT - Comprehensive linting
# ==============================================================================
if check_tool pylint; then
run_tool "pylint" "pylint --rcfile=pyproject.toml --jobs=0 --fail-under=10 ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# BANDIT - Security linting
# ==============================================================================
if check_tool bandit; then
run_tool "bandit" "bandit -c pyproject.toml -r ${TARGET_FILES} --severity-level low --confidence-level low" || OVERALL_STATUS=1
fi
# ==============================================================================
# VULTURE - Dead code detection
# ==============================================================================
if check_tool vulture; then
run_tool "vulture" "vulture --min-confidence 80 ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# FLAKE8 - Traditional linter
# ==============================================================================
if check_tool flake8; then
run_tool "flake8" "flake8 --max-line-length=88 --extend-ignore=E203,W503 --max-complexity=10 ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# PYCODESTYLE - PEP 8 style checker
# ==============================================================================
if check_tool pycodestyle; then
run_tool "pycodestyle" "pycodestyle --max-line-length=88 --ignore=E203,W503 ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# PYDOCSTYLE - Docstring style checker
# ==============================================================================
if check_tool pydocstyle; then
run_tool "pydocstyle" "pydocstyle --convention=google ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# RADON - Complexity metrics
# ==============================================================================
if check_tool radon; then
print_subheader "Running radon (complexity analysis)..."
echo ""
echo -e "${MAGENTA}Cyclomatic Complexity:${NC}"
radon cc -a -s "${TARGET_FILES_ARR[@]}" || true
echo ""
echo -e "${MAGENTA}Maintainability Index:${NC}"
radon mi -s "${TARGET_FILES_ARR[@]}" || true
if [[ "${REPORT_MODE}" == true ]]; then
radon cc -a -s "${TARGET_FILES_ARR[@]}" >"${PROJECT_ROOT}/lint-reports/radon-cc.txt" 2>&1 || true
radon mi -s "${TARGET_FILES_ARR[@]}" >"${PROJECT_ROOT}/lint-reports/radon-mi.txt" 2>&1 || true
fi
fi
# ==============================================================================
# INTERROGATE - Docstring coverage
# ==============================================================================
if check_tool interrogate; then
run_tool "interrogate" "interrogate -v --fail-under=0 ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# PYRIGHT - Microsoft's type checker (optional, very strict)
# ==============================================================================
if check_tool pyright; then
run_tool "pyright" "pyright ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# AUTOFLAKE - Unused imports/variables (fix mode only)
# ==============================================================================
if [[ "${FIX_MODE}" == true ]] && check_tool autoflake; then
print_subheader "Running autoflake (removing unused imports)..."
find "${TARGET_FILES_ARR[@]}" -name "*.py" -type f -exec autoflake --in-place --remove-all-unused-imports --remove-unused-variables {} \;
print_success "autoflake completed"
fi
# ==============================================================================
# PYUPGRADE - Upgrade Python syntax (fix mode only)
# ==============================================================================
if [[ "${FIX_MODE}" == true ]] && check_tool pyupgrade; then
print_subheader "Running pyupgrade (upgrading syntax to Python 3.10+)..."
find "${TARGET_FILES_ARR[@]}" -name "*.py" -type f -exec pyupgrade --py310-plus {} \;
print_success "pyupgrade completed"
fi
# ==============================================================================
# CODESPELL - Spell checking
# ==============================================================================
if check_tool codespell; then
if [[ "${FIX_MODE}" == true ]]; then
run_tool "codespell" "codespell -w --skip='*.json,*.lock,.git,__pycache__,.venv' ${TARGET_FILES}" || OVERALL_STATUS=1
else
run_tool "codespell" "codespell --skip='*.json,*.lock,.git,__pycache__,.venv' ${TARGET_FILES}" || OVERALL_STATUS=1
fi
fi
}

95
meta/lib/lint_output.sh Executable file
View File

@ -0,0 +1,95 @@
#!/bin/bash
# ==============================================================================
# Output formatting and tool-running helpers for lint_python.sh.
#
# Split out to keep the entry script under the 250-line cap. The seam passes
# state: run_tool appends to the caller's FAILED_TOOLS array and reads
# REPORT_MODE and PROJECT_ROOT, all of which lint_python.sh defines before
# sourcing this file. The colour variables come from the caller too.
# ==============================================================================
# shellcheck shell=bash
usage() {
echo "Usage: $1 [OPTIONS] [FILES...]"
echo ""
echo "Options:"
echo " --fix, -f Auto-fix issues where possible"
echo " --quick, -q Quick mode (ruff + mypy only)"
echo " --report, -r Generate detailed reports to ./lint-reports/"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
echo " $1 # Lint all Python files"
echo " $1 --fix # Lint and auto-fix"
echo " $1 PYTHON/ # Lint specific directory"
echo " $1 --quick --fix # Quick lint with auto-fix"
}
print_header() {
echo ""
echo -e "${BOLD}${BLUE}══════════════════════════════════════════════════════════════${NC}"
echo -e "${BOLD}${BLUE} $1${NC}"
echo -e "${BOLD}${BLUE}══════════════════════════════════════════════════════════════${NC}"
}
print_subheader() {
echo ""
echo -e "${CYAN}──────────────────────────────────────────────────────────────${NC}"
echo -e "${CYAN} $1${NC}"
echo -e "${CYAN}──────────────────────────────────────────────────────────────${NC}"
}
print_success() {
echo -e "${GREEN}✓${NC} $1"
}
print_warning() {
echo -e "${YELLOW}⚠${NC} $1"
}
print_error() {
echo -e "${RED}✗${NC} $1"
}
print_info() {
echo -e "${BLUE}ℹ${NC} $1"
}
run_tool() {
local tool_name="$1"
local tool_cmd="$2"
local report_file="${PROJECT_ROOT}/lint-reports/${tool_name}.txt"
print_subheader "Running ${tool_name}..."
if [[ "${REPORT_MODE}" == true ]]; then
if eval "${tool_cmd}" 2>&1 | tee "${report_file}"; then
print_success "${tool_name} passed"
return 0
else
print_error "${tool_name} found issues (see ${report_file})"
FAILED_TOOLS+=("${tool_name}")
return 1
fi
else
if eval "${tool_cmd}"; then
print_success "${tool_name} passed"
return 0
else
print_error "${tool_name} found issues"
FAILED_TOOLS+=("${tool_name}")
return 1
fi
fi
}
check_tool() {
if command -v "$1" &>/dev/null; then
return 0
else
print_warning "$1 not found, skipping..."
return 1
fi
}

View File

@ -25,6 +25,10 @@ BOLD='\033[1m'
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="${SCRIPT_DIR}"
# Sourced before argument parsing: --help calls usage() from here.
# shellcheck source=./lib/lint_output.sh
source "${SCRIPT_DIR}/lib/lint_output.sh"
PYTHON_PATHS=(
"PYTHON"
"articles"
@ -73,19 +77,7 @@ while [[ $# -gt 0 ]]; do
shift
;;
--help | -h)
echo "Usage: $0 [OPTIONS] [FILES...]"
echo ""
echo "Options:"
echo " --fix, -f Auto-fix issues where possible"
echo " --quick, -q Quick mode (ruff + mypy only)"
echo " --report, -r Generate detailed reports to ./lint-reports/"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
echo " $0 # Lint all Python files"
echo " $0 --fix # Lint and auto-fix"
echo " $0 PYTHON/ # Lint specific directory"
echo " $0 --quick --fix # Quick lint with auto-fix"
usage "$0"
exit 0
;;
*)
@ -111,77 +103,6 @@ fi
OVERALL_STATUS=0
FAILED_TOOLS=()
# ==============================================================================
# Helper functions
# ==============================================================================
print_header() {
echo ""
echo -e "${BOLD}${BLUE}══════════════════════════════════════════════════════════════${NC}"
echo -e "${BOLD}${BLUE} $1${NC}"
echo -e "${BOLD}${BLUE}══════════════════════════════════════════════════════════════${NC}"
}
print_subheader() {
echo ""
echo -e "${CYAN}──────────────────────────────────────────────────────────────${NC}"
echo -e "${CYAN} $1${NC}"
echo -e "${CYAN}──────────────────────────────────────────────────────────────${NC}"
}
print_success() {
echo -e "${GREEN}✓${NC} $1"
}
print_warning() {
echo -e "${YELLOW}⚠${NC} $1"
}
print_error() {
echo -e "${RED}✗${NC} $1"
}
print_info() {
echo -e "${BLUE}ℹ${NC} $1"
}
run_tool() {
local tool_name="$1"
local tool_cmd="$2"
local report_file="${PROJECT_ROOT}/lint-reports/${tool_name}.txt"
print_subheader "Running ${tool_name}..."
if [[ "${REPORT_MODE}" == true ]]; then
if eval "${tool_cmd}" 2>&1 | tee "${report_file}"; then
print_success "${tool_name} passed"
return 0
else
print_error "${tool_name} found issues (see ${report_file})"
FAILED_TOOLS+=("${tool_name}")
return 1
fi
else
if eval "${tool_cmd}"; then
print_success "${tool_name} passed"
return 0
else
print_error "${tool_name} found issues"
FAILED_TOOLS+=("${tool_name}")
return 1
fi
fi
}
check_tool() {
if command -v "$1" &>/dev/null; then
return 0
else
print_warning "$1 not found, skipping..."
return 1
fi
}
# ==============================================================================
# Main linting process
# ==============================================================================
@ -227,108 +148,9 @@ if [[ "${QUICK_MODE}" == true ]]; then
fi
fi
# ==============================================================================
# PYLINT - Comprehensive linting
# ==============================================================================
if check_tool pylint; then
run_tool "pylint" "pylint --rcfile=pyproject.toml --jobs=0 --fail-under=10 ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# BANDIT - Security linting
# ==============================================================================
if check_tool bandit; then
run_tool "bandit" "bandit -c pyproject.toml -r ${TARGET_FILES} --severity-level low --confidence-level low" || OVERALL_STATUS=1
fi
# ==============================================================================
# VULTURE - Dead code detection
# ==============================================================================
if check_tool vulture; then
run_tool "vulture" "vulture --min-confidence 80 ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# FLAKE8 - Traditional linter
# ==============================================================================
if check_tool flake8; then
run_tool "flake8" "flake8 --max-line-length=88 --extend-ignore=E203,W503 --max-complexity=10 ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# PYCODESTYLE - PEP 8 style checker
# ==============================================================================
if check_tool pycodestyle; then
run_tool "pycodestyle" "pycodestyle --max-line-length=88 --ignore=E203,W503 ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# PYDOCSTYLE - Docstring style checker
# ==============================================================================
if check_tool pydocstyle; then
run_tool "pydocstyle" "pydocstyle --convention=google ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# RADON - Complexity metrics
# ==============================================================================
if check_tool radon; then
print_subheader "Running radon (complexity analysis)..."
echo ""
echo -e "${MAGENTA}Cyclomatic Complexity:${NC}"
radon cc -a -s "${TARGET_FILES_ARR[@]}" || true
echo ""
echo -e "${MAGENTA}Maintainability Index:${NC}"
radon mi -s "${TARGET_FILES_ARR[@]}" || true
if [[ "${REPORT_MODE}" == true ]]; then
radon cc -a -s "${TARGET_FILES_ARR[@]}" >"${PROJECT_ROOT}/lint-reports/radon-cc.txt" 2>&1 || true
radon mi -s "${TARGET_FILES_ARR[@]}" >"${PROJECT_ROOT}/lint-reports/radon-mi.txt" 2>&1 || true
fi
fi
# ==============================================================================
# INTERROGATE - Docstring coverage
# ==============================================================================
if check_tool interrogate; then
run_tool "interrogate" "interrogate -v --fail-under=0 ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# PYRIGHT - Microsoft's type checker (optional, very strict)
# ==============================================================================
if check_tool pyright; then
run_tool "pyright" "pyright ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# AUTOFLAKE - Unused imports/variables (fix mode only)
# ==============================================================================
if [[ "${FIX_MODE}" == true ]] && check_tool autoflake; then
print_subheader "Running autoflake (removing unused imports)..."
find "${TARGET_FILES_ARR[@]}" -name "*.py" -type f -exec autoflake --in-place --remove-all-unused-imports --remove-unused-variables {} \;
print_success "autoflake completed"
fi
# ==============================================================================
# PYUPGRADE - Upgrade Python syntax (fix mode only)
# ==============================================================================
if [[ "${FIX_MODE}" == true ]] && check_tool pyupgrade; then
print_subheader "Running pyupgrade (upgrading syntax to Python 3.10+)..."
find "${TARGET_FILES_ARR[@]}" -name "*.py" -type f -exec pyupgrade --py310-plus {} \;
print_success "pyupgrade completed"
fi
# ==============================================================================
# CODESPELL - Spell checking
# ==============================================================================
if check_tool codespell; then
if [[ "${FIX_MODE}" == true ]]; then
run_tool "codespell" "codespell -w --skip='*.json,*.lock,.git,__pycache__,.venv' ${TARGET_FILES}" || OVERALL_STATUS=1
else
run_tool "codespell" "codespell --skip='*.json,*.lock,.git,__pycache__,.venv' ${TARGET_FILES}" || OVERALL_STATUS=1
fi
fi
# shellcheck source=./lib/lint_full_tools.sh
source "${SCRIPT_DIR}/lib/lint_full_tools.sh"
run_full_mode_linters
# ==============================================================================
# Summary
@ -346,7 +168,7 @@ if [[ ${OVERALL_STATUS} -ne 0 ]]; then
print_info "Detailed reports saved to: ${PROJECT_ROOT}/lint-reports/"
fi
print_info "Run with --fix to auto-fix issues where possible"
exit ${OVERALL_STATUS}
exit "${OVERALL_STATUS}"
else
print_success "All linting checks passed!"
exit 0