From 97a722d2b2fb508df61a0bed96e85301b0c838c5 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 16 Aug 2026 23:08:02 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY --- .../lint-python-split-2026-08-16.json | 19 ++ .../lint-python-split-2026-08-16.json | 49 +++++ meta/lib/lint_full_tools.sh | 123 +++++++++++ meta/lib/lint_output.sh | 95 +++++++++ meta/lint_python.sh | 196 +----------------- 5 files changed, 295 insertions(+), 187 deletions(-) create mode 100644 docs/superpowers/contracts/lint-python-split-2026-08-16.json create mode 100644 docs/superpowers/evidence/lint-python-split-2026-08-16.json create mode 100755 meta/lib/lint_full_tools.sh create mode 100755 meta/lib/lint_output.sh diff --git a/docs/superpowers/contracts/lint-python-split-2026-08-16.json b/docs/superpowers/contracts/lint-python-split-2026-08-16.json new file mode 100644 index 00000000..d4e34ba2 --- /dev/null +++ b/docs/superpowers/contracts/lint-python-split-2026-08-16.json @@ -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." +} diff --git a/docs/superpowers/evidence/lint-python-split-2026-08-16.json b/docs/superpowers/evidence/lint-python-split-2026-08-16.json new file mode 100644 index 00000000..3b7d0c3a --- /dev/null +++ b/docs/superpowers/evidence/lint-python-split-2026-08-16.json @@ -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 (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!'." + ] +} diff --git a/meta/lib/lint_full_tools.sh b/meta/lib/lint_full_tools.sh new file mode 100755 index 00000000..c2fc5b83 --- /dev/null +++ b/meta/lib/lint_full_tools.sh @@ -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 +} diff --git a/meta/lib/lint_output.sh b/meta/lib/lint_output.sh new file mode 100755 index 00000000..b07c7400 --- /dev/null +++ b/meta/lib/lint_output.sh @@ -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 +} diff --git a/meta/lint_python.sh b/meta/lint_python.sh index f9c63411..34ba2a81 100755 --- a/meta/lint_python.sh +++ b/meta/lint_python.sh @@ -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