From cb4a97e880ccca282efd51bae787f18884f94acf Mon Sep 17 00:00:00 2001 From: Lasse Benninga Date: Fri, 22 May 2026 13:47:20 +0200 Subject: [PATCH 1/3] feat(autograder): add shared grader_lib + code hygiene warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same grader_lib.sh as week-1 — shared bash library with pass/fail/warn helpers and static-analysis checks from c55 PR review analysis. New warning checks for week-2 specific patterns: - check_silent_zero_in_except: AST-based detection of 'x = 0' in except blocks (reviewer c55#3: "sets price/revenue/vat to 0 instead of skipping the row — silently corrupts output") - check_exception_logged: warns when except-block log call doesn't include the exception variable 'e' (reviewer c55#5,#7: "log the error type!") - check_ruff F401/E302: unused imports + missing blank lines between functions (c55#3,#1: "This import isn't used" / "add new line after func") - check_no_print_statements: logging over print() - check_no_notimplemented: stubs left in (c55#3) - check_gitignore_python: __pycache__/ and .env ignored - AI_DEBUG.md traceback check - Screenshot format: 7/20 pts (warn) for .jpg, full 10/20 for .png Scoring ladder unchanged (60/20/20, passing=60). --- .hyf/grader_lib.sh | 250 +++++++++++++++++++++++++++++++++++++++++++++ .hyf/test.sh | 86 ++++++++++++---- 2 files changed, 318 insertions(+), 18 deletions(-) create mode 100755 .hyf/grader_lib.sh diff --git a/.hyf/grader_lib.sh b/.hyf/grader_lib.sh new file mode 100755 index 0000000..3478d4f --- /dev/null +++ b/.hyf/grader_lib.sh @@ -0,0 +1,250 @@ +#!/usr/bin/env bash +# grader_lib.sh — shared helpers for HYF Data Track autograders. +# Source this at the top of test.sh: +# source "$(dirname "$0")/grader_lib.sh" +# +# Provides: pass(), fail(), warn(), print_results(), write_score(), +# and a set of common static-analysis checks derived from recurring +# PR review patterns across cohort c55. + +_grader_details=() + +pass() { _grader_details+=("✓ PASS $1"); } +fail() { _grader_details+=("✗ FAIL $1"); } +warn() { _grader_details+=("⚠ WARN $1"); } + +print_results() { + local header="${1:-Autograder Results}" + echo "" + echo "=== $header ===" + for line in "${_grader_details[@]}"; do echo " $line"; done + echo "" +} + +write_score() { + # write_score [] + local score="$1" + local passing="$2" + local outfile="${3:-$(dirname "${BASH_SOURCE[0]}")/score.json}" + local pass_flag="false" + [[ "$score" -ge "$passing" ]] && pass_flag="true" + cat > "$outfile" << JSON +{ + "score": $score, + "pass": $pass_flag, + "passingScore": $passing +} +JSON + echo "Score: $score / 100 (passing: $passing) pass=$pass_flag" +} + +# ── Common static-analysis checks ──────────────────────────────────────────── +# Each function: returns 0 on pass, 1 on fail/warn (for caller logic). +# All feedback goes through pass()/fail()/warn() so it appears in print_results. + +check_no_print_statements() { + # Usage: check_no_print_statements [label] + # Flags bare print() calls that should be logging calls. + local dir="${1:-.}" + local label="${2:-$dir}" + local found + found=$(grep -rn "^[[:space:]]*print(" "$dir" --include="*.py" 2>/dev/null | grep -v "# noqa" || true) + if [[ -n "$found" ]]; then + local count + count=$(echo "$found" | wc -l | tr -d ' ') + warn "$label: $count print() call(s) found — use logging.info/warning/error instead (see Week 1 Ch1)" + return 1 + fi + return 0 +} + +check_no_notimplemented() { + # Usage: check_no_notimplemented [label] + # Flags NotImplementedError stubs left in after implementation. + local dir="${1:-.}" + local label="${2:-$dir}" + local found + found=$(grep -rn "raise NotImplementedError" "$dir" --include="*.py" 2>/dev/null || true) + if [[ -n "$found" ]]; then + fail "$label: raise NotImplementedError still present — remove stubs before submitting" + return 1 + fi + return 0 +} + +check_no_relative_imports() { + # Usage: check_no_relative_imports [label] + # Flags `from .module import x` in scripts not inside a proper package. + # Relative imports break the grader: python3 src/cleaner.py fails with + # "attempted relative import with no known parent package". + local dir="${1:-.}" + local label="${2:-$dir}" + local found + found=$(grep -rn "^from \." "$dir" --include="*.py" 2>/dev/null || true) + if [[ -n "$found" ]]; then + fail "$label: relative import found (from .module) — use absolute: 'from src.module import x'" + return 1 + fi + return 0 +} + +check_no_logging_in_utils() { + # Usage: check_no_logging_in_utils + # utils.py should be pure helpers; logging config belongs in the entry point. + local file="${1:-task-1/src/utils.py}" + if [[ ! -f "$file" ]]; then return 0; fi + if grep -qE "logging\.basicConfig|logging\.getLogger" "$file"; then + warn "$file: logging.basicConfig/getLogger found — logging setup belongs in cleaner.py or the entry-point, not in utils" + return 1 + fi + return 0 +} + +check_gitignore_python() { + # Usage: check_gitignore_python [] + # Warns when Python cache patterns are absent from .gitignore. + local gi="${1:-.gitignore}" + if [[ ! -f "$gi" ]]; then + warn ".gitignore is missing — add one so __pycache__/ and *.pyc are not committed" + return 1 + fi + local ok=true + if ! grep -q "__pycache__" "$gi"; then + warn ".gitignore missing __pycache__/ — Python bytecode cache dirs should not be committed" + ok=false + fi + if ! grep -qE "^\*\.pyc$|^.*\*\.pyc" "$gi"; then + warn ".gitignore missing *.pyc — compiled Python files should not be committed" + ok=false + fi + if ! grep -qE "^\.env$|^\.env\b" "$gi"; then + warn ".gitignore missing .env — secret files should not be committed" + ok=false + fi + [[ "$ok" = true ]] && pass ".gitignore correctly excludes __pycache__/, *.pyc, and .env" +} + +check_screenshot_is_png() { + # Usage: check_screenshot_is_png [] + # Awards full credit for .png, warns (and still credits) for .jpg/.jpeg, + # zero for missing. Matches the pattern flagged in c55 PR reviews. + local expected_png="$1" + local dir + dir="$(dirname "$expected_png")" + local base + base="$(basename "$expected_png" .png)" + + if [[ -s "$expected_png" ]]; then + pass "screenshot is $expected_png (.png format ✓)" + return 0 + fi + for ext in jpg jpeg; do + if [[ -s "$dir/$base.$ext" ]]; then + warn "screenshot is .$ext but should be .png — rename to $base.png (partial credit still given)" + return 1 + fi + done + fail "screenshot missing: $expected_png not found" + return 2 +} + +check_silent_zero_in_except() { + # Usage: check_silent_zero_in_except + # Detects the pattern: try: x = compute() / except: x = 0 + # which silently corrupts data instead of skipping or raising. + local file="$1" + if [[ ! -f "$file" ]]; then return 0; fi + local found + found=$(python3 - "$file" 2>/dev/null << 'PY' +import ast, sys +try: + tree = ast.parse(open(sys.argv[1]).read()) +except SyntaxError: + sys.exit(0) +for node in ast.walk(tree): + if isinstance(node, ast.ExceptHandler): + for stmt in node.body: + if isinstance(stmt, ast.Assign): + if isinstance(stmt.value, ast.Constant) and stmt.value.value == 0: + print(f"line {stmt.lineno}: '{ast.unparse(stmt)}' — sets field to 0 in except block (silent data corruption)") +PY +) + if [[ -n "$found" ]]; then + warn "$file: silent 0-assignment in except block — skip the row or raise instead of setting to 0:\n $found" + return 1 + fi + return 0 +} + +check_exception_logged() { + # Usage: check_exception_logged + # Warns when except blocks log/print a message but don't include the + # exception variable (e, err, exc), meaning the error type is lost. + local dir="${1:-.}" + local found + found=$(python3 - "$dir" 2>/dev/null << 'PY' +import ast, os, sys +issues = [] +for root, _, files in os.walk(sys.argv[1]): + for fname in files: + if not fname.endswith(".py"): + continue + path = os.path.join(root, fname) + try: + tree = ast.parse(open(path).read()) + except SyntaxError: + continue + for node in ast.walk(tree): + if not isinstance(node, ast.ExceptHandler): + continue + exc_var = node.name # e.g. "e" in `except ValueError as e` + if not exc_var: + continue + for stmt in node.body: + for call in ast.walk(stmt): + if not isinstance(call, ast.Call): + continue + # Is it a logging.* or print call? + func = call.func + is_log = (isinstance(func, ast.Attribute) and + isinstance(func.value, ast.Name) and + func.value.id == "logging") + is_print = isinstance(func, ast.Name) and func.id == "print" + if not (is_log or is_print): + continue + # Does the call reference the exception variable? + src = ast.unparse(call) + if exc_var not in src: + issues.append(f"{path}:{call.lineno}: log message doesn't include exception variable '{exc_var}' — add it for easier debugging") +if issues: + for i in issues[:3]: # cap at 3 to keep output readable + print(i) +PY +) + if [[ -n "$found" ]]; then + warn "exception variable not included in log message (harder to debug):\n $found" + return 1 + fi + return 0 +} + +check_ruff() { + # Usage: check_ruff [] - # Runs ruff on if available; warns on violations. - # Default select: F401 (unused imports), E302 (missing blank lines). + # Runs ruff if available; warns on F401 (unused imports) and E302/E303 (blank lines). local dir="${1:-.}" local select="${2:-F401,E302,E303}" if ! command -v ruff &>/dev/null && ! python3 -m ruff --version &>/dev/null 2>&1; then - return 0 # ruff not installed — skip silently + return 0 fi local out out=$(python3 -m ruff check --select="$select" --output-format=text "$dir" 2>/dev/null || true) @@ -243,8 +231,8 @@ check_ruff() { local count count=$(echo "$out" | grep -c "\.py:" || true) warn "$dir: ruff found $count style issue(s) (unused imports / missing blank lines) — run 'ruff check $dir' to see details" - return 1 + else + pass "$dir: no ruff style issues (F401/E302/E303)" fi - pass "$dir: no ruff style issues (F401/E302/E303)" return 0 }