diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e25873e9..238cacab 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -100,6 +100,11 @@ repos: # =========================================================================== - repo: local hooks: + - id: no-inline-python + name: Block Python logic embedded in shell scripts + entry: meta/scripts/check_no_inline_python.sh + language: script + types_or: [shell, zsh] - id: no-polling-antipatterns name: Block polling script anti-patterns entry: meta/scripts/check_polling_antipatterns.sh diff --git a/docs/superpowers/evidence/no-inline-python-gate-2026-08-16.json b/docs/superpowers/evidence/no-inline-python-gate-2026-08-16.json new file mode 100644 index 00000000..41646605 --- /dev/null +++ b/docs/superpowers/evidence/no-inline-python-gate-2026-08-16.json @@ -0,0 +1,45 @@ +{ + "intent": "Make the 'no inline Python in shell scripts' rule a gate that fails the commit, instead of prose in CLAUDE.md that nothing enforced.", + "scope": [ + "meta/scripts/check_no_inline_python.sh (new pre-commit hook script)", + ".pre-commit-config.yaml (register the no-inline-python hook)", + "linux_configuration/scripts/single_use/utils/install_plagiarism_tools.sh (the one real violation)", + "linux_configuration/scripts/single_use/utils/download_nltk_data.py (extracted from the inline block)", + "Non-goal: the remaining 250-line file-length work; that lands separately" + ], + "changes": [ + "Added check_no_inline_python.sh: flags `python < int: + """Download every corpus, reporting any that fail. + + Returns 1 if any download failed, so the calling shell script's `set -e` + sees a real failure instead of a silent partial install. + """ + # Imported dynamically, not at module scope: nltk belongs to the plagiarism + # toolchain rather than this repo, so it is deliberately absent from + # meta/requirements.txt and unresolvable to static analysis. The installer + # pip-installs it immediately before invoking this module. + try: + nltk = importlib.import_module("nltk") + except ImportError: + sys.stderr.write("error: nltk is not installed\n") + return 1 + + failed = [corpus for corpus in CORPORA if not nltk.download(corpus)] + if failed: + sys.stderr.write(f"error: failed to download: {', '.join(failed)}\n") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/linux_configuration/scripts/single_use/utils/install_plagiarism_tools.sh b/linux_configuration/scripts/single_use/utils/install_plagiarism_tools.sh index ed8b4572..39560902 100755 --- a/linux_configuration/scripts/single_use/utils/install_plagiarism_tools.sh +++ b/linux_configuration/scripts/single_use/utils/install_plagiarism_tools.sh @@ -81,14 +81,7 @@ success "Python NLP packages installed" # Download NLTK data echo "Downloading NLTK data (stopwords, punkt tokenizer)..." -python3 -c " -import nltk -nltk.download('punkt') -nltk.download('stopwords') -nltk.download('punkt_tab') -nltk.download('averaged_perceptron_tagger') -nltk.download('wordnet') -" +python3 "$(dirname "$0")/download_nltk_data.py" success "NLTK data downloaded" # Download spaCy English model (small) diff --git a/meta/scripts/check_no_inline_python.sh b/meta/scripts/check_no_inline_python.sh new file mode 100755 index 00000000..de7a82b5 --- /dev/null +++ b/meta/scripts/check_no_inline_python.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Block Python program logic embedded inline in a shell script. +# +# Inline Python is invisible to the repo's Python tooling: ruff, mypy, pylint, +# bandit and pytest all skip it, so it is the one place a type error or a +# security finding can live undetected. Extract it to a .py file under +# python_pkg/ and invoke it as `python3 "$(dirname "$0")/helper.py" "$arg"`. +# +# Permitted, and deliberately not flagged: a SINGLE-LINE availability or +# version probe with no logic, e.g. +# python3 -c 'import kasa' +# python -c "import sys; print(sys.version_info[0])" +# These carry no logic worth linting -- they are a yes/no question about the +# interpreter, and extracting them to a file would be sillier than inlining. +# +# Used as a pre-commit hook; receives staged file paths as arguments. + +set -uo pipefail + +errors=() + +# A `python <out` is caught. +readonly HEREDOC_RE='(^|[^[:alnum:]_])python3?[[:space:]]+<<-?' + +# The opening of a `python -c "` whose closing quote is NOT on the same line, +# i.e. a multi-line inline program. A single-line -c closes its own quote and +# so does not match. +readonly MULTILINE_C_RE='(^|[^[:alnum:]_])python3?[[:space:]]+(-[[:alnum:]]+[[:space:]]+)*-c[[:space:]]*("[^"]*$|'"'"'[^'"'"']*$)' + +for file in "$@"; do + case "$file" in + *.sh | *.bash | *.zsh) ;; + *) continue ;; + esac + [[ -f "$file" ]] || continue + + while IFS=: read -r lineno text; do + [[ -z "${lineno:-}" ]] && continue + stripped="$(printf '%s' "$text" | sed 's/^[[:space:]]*//')" + # A commented-out example is documentation, not shipped logic. Without + # this, the rule's own explanatory comments would fail the hook. + [[ "$stripped" == \#* ]] && continue + errors+=("$file:$lineno: $stripped") + done < <(grep -nE "$HEREDOC_RE|$MULTILINE_C_RE" "$file" 2>/dev/null || true) +done + +if [[ ${#errors[@]} -gt 0 ]]; then + echo "ERROR: Python program logic embedded in a shell script." + echo "Inline Python is skipped by ruff, mypy, pylint, bandit and pytest." + echo "" + for err in "${errors[@]}"; do + echo " $err" + done + echo "" + echo "Move the code to its own .py file and call it:" + echo " python3 \"\$(dirname \"\$0\")/helper.py\" \"\$arg\" # bash" + echo " python3 \"\${0:A:h}/helper.py\" \"\$arg\" # zsh" + echo "" + echo "A single-line probe such as python3 -c 'import kasa' is allowed." + exit 1 +fi