diff --git a/.claude/config/rules.yaml b/.claude/config/rules.yaml index 1428f0fa..15c2bfbf 100644 --- a/.claude/config/rules.yaml +++ b/.claude/config/rules.yaml @@ -71,8 +71,8 @@ rules: BDD-02: { tier: BLOCK } # -------- PATTERNS -------- - PY-PT-01: { tier: BLOCK, predicates: { module_shape: client } } - JV-PT-01: { tier: BLOCK, predicates: { module_shape: client } } + PY-PT-01: { tier: FLAG, predicates: { module_shape: client } } + JV-PT-01: { tier: FLAG, predicates: { module_shape: client } } PY-PT-03: { tier: FLAG } PY-PT-04: { tier: FLAG } PY-PT-08: { tier: FLAG } diff --git a/.claude/scripts/check-bdd.sh b/.claude/scripts/check-bdd.sh index 586002b5..791ccefe 100755 --- a/.claude/scripts/check-bdd.sh +++ b/.claude/scripts/check-bdd.sh @@ -64,16 +64,28 @@ while IFS= read -r mod; do [ -z "$mod" ] && continue # Path for THIS repo's feature file + # FP-C-01: BDD-01 must accept ANY .feature file under the module's integration + # dir, not just `.feature`. if [ "$LANGUAGE" = "python" ]; then - feature_path="$REPO_ROOT/tests/$mod/integration/$mod.feature" + feature_dir="$REPO_ROOT/tests/$mod/integration" + feature_path="$feature_dir/$mod.feature" # kept for BDD-02 sibling comparison mod_dir="$REPO_ROOT/src/sap_cloud_sdk/$mod" else - feature_path="$REPO_ROOT/src/test/resources/com/sap/applicationfoundation/$mod/integration/$mod.feature" + feature_dir="$REPO_ROOT/src/test/resources/com/sap/applicationfoundation/$mod/integration" + feature_path="$feature_dir/$mod.feature" # kept for BDD-02 sibling comparison mod_dir="$REPO_ROOT/src/main/java/com/sap/cloud/sdk/$mod" fi - # BDD-01: feature file exists (only fire if module has any source files) - if [ ! -f "$feature_path" ] && [ -d "$mod_dir" ]; then + # BDD-01: at least one .feature file exists in the module's integration dir + # (only fire if module has any source files and PR creates the module). + has_any_feature=false + if [ -d "$feature_dir" ]; then + if compgen -G "$feature_dir/*.feature" > /dev/null 2>&1; then + has_any_feature=true + fi + fi + + if [ "$has_any_feature" = "false" ] && [ -d "$mod_dir" ]; then # Detect if this PR creates any new file INSIDE the module. # `new file mode` is on its own line before the `+++ b/` header, # so we scan block-by-block to link them. @@ -89,9 +101,9 @@ while IFS= read -r mod; do } ') if [ "$is_new_module" = "true" ]; then - emit_finding "BDD-01" "BLOCK" "tests/$mod/integration/$mod.feature" 1 \ - "New module '$mod' has no BDD feature file" \ - "Create $feature_path with cross-language-consistent scenarios" >> "$findings" + emit_finding "BDD-01" "BLOCK" "tests/$mod/integration/" 1 \ + "New module '$mod' has no .feature files under integration/" \ + "Create at least one $feature_dir/*.feature with cross-language-consistent scenarios" >> "$findings" fi fi diff --git a/.claude/scripts/check-concurrency.sh b/.claude/scripts/check-concurrency.sh index 6405efdf..d420a6ff 100755 --- a/.claude/scripts/check-concurrency.sh +++ b/.claude/scripts/check-concurrency.sh @@ -4,6 +4,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib/json-emit.sh" +source "$SCRIPT_DIR/lib/hunk-filter.sh" source "$SCRIPT_DIR/lib/skill-self-skip.sh" LANGUAGE="${LANGUAGE:-python}" @@ -27,7 +28,7 @@ echo "$diff_content" | awk ' if echo "$content" | grep -qE 'asyncio\.Queue\('; then # Check if same file has a Lock or set() nearby if [ -f "$REPO_ROOT/$file" ] && ! grep -qE 'asyncio\.Lock|set\(\)' "$REPO_ROOT/$file" 2>/dev/null; then - emit_finding "CC-01" "FLAG" "$file" "$line_num" \ + emit_finding_if_touched "CC-01" "FLAG" "$file" "$line_num" \ "asyncio.Queue without dedup — if items must be unique, add a set() + asyncio.Lock" "" >> "$findings" fi fi diff --git a/.claude/scripts/check-constants.sh b/.claude/scripts/check-constants.sh index 229a2cc7..3b561a3b 100755 --- a/.claude/scripts/check-constants.sh +++ b/.claude/scripts/check-constants.sh @@ -4,6 +4,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib/json-emit.sh" +source "$SCRIPT_DIR/lib/hunk-filter.sh" source "$SCRIPT_DIR/lib/skill-self-skip.sh" LANGUAGE="${LANGUAGE:-python}" @@ -25,8 +26,20 @@ if [ "$LANGUAGE" = "python" ]; then filtered="$filtered $f" done <<< "$changed" if [ -n "$filtered" ]; then + raw_con=$(mktemp); trap 'rm -f "$raw_con"' EXIT # shellcheck disable=SC2086 - python3 "$SCRIPT_DIR/lib/ast_python_checks.py" con-01 $filtered 2>/dev/null >> "$findings" || true + python3 "$SCRIPT_DIR/lib/ast_python_checks.py" con-01 $filtered 2>/dev/null > "$raw_con" || true + # FP-A-01: filter to touched lines only + while IFS= read -r finding; do + [ -z "$finding" ] && continue + f=$(echo "$finding" | python3 -c "import json,sys;o=json.loads(sys.stdin.read());print(o.get('file',''))") + ln=$(echo "$finding" | python3 -c "import json,sys;o=json.loads(sys.stdin.read());print(o.get('line',0))") + [ -z "$f" ] && continue + if is_line_touched "$f" "$ln"; then + echo "$finding" >> "$findings" + fi + done < "$raw_con" + rm -f "$raw_con" fi fi diff --git a/.claude/scripts/check-deletion-hygiene.sh b/.claude/scripts/check-deletion-hygiene.sh index fba1ad49..26b516d5 100755 --- a/.claude/scripts/check-deletion-hygiene.sh +++ b/.claude/scripts/check-deletion-hygiene.sh @@ -4,6 +4,10 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib/json-emit.sh" +# DEL-01 uses synthetic path "src/:1" — hunk-filter is sourced for +# emit_finding_if_touched (via is_meta_finding, which treats non-file paths +# as metadata and passes them through). No filtering applied here. +source "$SCRIPT_DIR/lib/hunk-filter.sh" LANGUAGE="${LANGUAGE:-python}" DIFF_FILE="${DIFF_FILE:-/dev/stdin}" diff --git a/.claude/scripts/check-disclosure.sh b/.claude/scripts/check-disclosure.sh index bccce3ce..cbe8e132 100755 --- a/.claude/scripts/check-disclosure.sh +++ b/.claude/scripts/check-disclosure.sh @@ -6,6 +6,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/json-emit.sh source "$SCRIPT_DIR/lib/json-emit.sh" +# shellcheck source=lib/hunk-filter.sh +source "$SCRIPT_DIR/lib/hunk-filter.sh" # shellcheck source=lib/skill-self-skip.sh source "$SCRIPT_DIR/lib/skill-self-skip.sh" @@ -39,16 +41,16 @@ echo "$diff_content" | awk ' # DIS-02: Internal Jira URL (checked first — more specific) dis02_fired=false if echo "$content" | grep -qEi 'jira\.tools\.sap|jira\.wdf\.sap\.corp'; then - emit_finding "DIS-02" "$(sev_public)" "$file" "$line_num" "Internal Jira URL — remove or move to internal-only docs" "" >> "$findings" + emit_finding_if_touched "DIS-02" "$(sev_public)" "$file" "$line_num" "Internal Jira URL — remove or move to internal-only docs" "" >> "$findings" dis02_fired=true fi # DIS-01: SAP-internal hostnames (skip if DIS-02 already covers the same line) if [ "$dis02_fired" = "false" ] && echo "$content" | grep -qEi '(int\.repositories\.cloud\.sap|\.tools\.sap|\.wdf\.sap\.corp|\.mo\.sap\.corp)'; then - emit_finding "DIS-01" "$(sev_public)" "$file" "$line_num" "SAP-internal hostname detected in code" "" >> "$findings" + emit_finding_if_touched "DIS-01" "$(sev_public)" "$file" "$line_num" "SAP-internal hostname detected in code" "" >> "$findings" fi # DIS-06: Internal artifactory index-url if echo "$content" | grep -qEi '\-\-index-url[[:space:]]+https?://int\.repositories\.cloud\.sap'; then - emit_finding "DIS-06" "BLOCK" "$file" "$line_num" "Internal artifactory --index-url — must not appear in public/shared configs" "" >> "$findings" + emit_finding_if_touched "DIS-06" "BLOCK" "$file" "$line_num" "Internal artifactory --index-url — must not appear in public/shared configs" "" >> "$findings" fi done diff --git a/.claude/scripts/check-errors-logging.sh b/.claude/scripts/check-errors-logging.sh index 2b8334c1..cedb03f4 100755 --- a/.claude/scripts/check-errors-logging.sh +++ b/.claude/scripts/check-errors-logging.sh @@ -4,6 +4,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib/json-emit.sh" +source "$SCRIPT_DIR/lib/hunk-filter.sh" source "$SCRIPT_DIR/lib/skill-self-skip.sh" LANGUAGE="${LANGUAGE:-python}" @@ -19,10 +20,22 @@ if [ "$LANGUAGE" = "python" ]; then changed=$(echo "$diff_content" | grep -oE '^\+\+\+ b/src/.*\.py' | sed 's|^+++ b/||' | sort -u) if [ -n "$changed" ]; then # AST-based chaining / swallow — only reports FLAGs when body ends non-Raise + # FP-A-01: filter AST hits through hunk attribution + raw_el=$(mktemp); trap 'rm -f "$raw_el"' EXIT # shellcheck disable=SC2086 - python3 "$SCRIPT_DIR/lib/ast_python_checks.py" el-01 $changed 2>/dev/null >> "$findings" || true + python3 "$SCRIPT_DIR/lib/ast_python_checks.py" el-01 $changed 2>/dev/null > "$raw_el" || true # shellcheck disable=SC2086 - python3 "$SCRIPT_DIR/lib/ast_python_checks.py" el-02 $changed 2>/dev/null >> "$findings" || true + python3 "$SCRIPT_DIR/lib/ast_python_checks.py" el-02 $changed 2>/dev/null >> "$raw_el" || true + while IFS= read -r finding; do + [ -z "$finding" ] && continue + f=$(echo "$finding" | python3 -c "import json,sys;o=json.loads(sys.stdin.read());print(o.get('file',''))") + ln=$(echo "$finding" | python3 -c "import json,sys;o=json.loads(sys.stdin.read());print(o.get('line',0))") + [ -z "$f" ] && continue + if is_line_touched "$f" "$ln"; then + echo "$finding" >> "$findings" + fi + done < "$raw_el" + rm -f "$raw_el" fi # EL-04: secret-like variable name in raise args (grep-based, added lines only) @@ -37,7 +50,7 @@ if [ "$LANGUAGE" = "python" ]; then if [ "$(is_skill_file "$file")" = "true" ]; then continue; fi # match raise ...({...token|secret|password|api_key|client_secret...}) if echo "$content" | grep -qE 'raise[[:space:]].*[fF]?"[^"]*\{[^}]*(token|secret|password|api_key|client_secret)[^}]*\}'; then - emit_finding "EL-04" "BLOCK" "$file" "$line_num" \ + emit_finding_if_touched "EL-04" "BLOCK" "$file" "$line_num" \ "Exception message includes secret-like variable — leaks sensitive data" "" >> "$findings" fi done diff --git a/.claude/scripts/check-hardcode.sh b/.claude/scripts/check-hardcode.sh index 11f98556..aa4791ef 100755 --- a/.claude/scripts/check-hardcode.sh +++ b/.claude/scripts/check-hardcode.sh @@ -5,6 +5,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/json-emit.sh source "$SCRIPT_DIR/lib/json-emit.sh" +# shellcheck source=lib/hunk-filter.sh +source "$SCRIPT_DIR/lib/hunk-filter.sh" # shellcheck source=lib/skill-self-skip.sh source "$SCRIPT_DIR/lib/skill-self-skip.sh" @@ -18,10 +20,12 @@ trap 'rm -f "$findings"' EXIT diff_content=$(cat "$DIFF_FILE" 2>/dev/null || echo "") # Determine ignore patterns per language +# FP-B-01: HC-01 must not fire on XML/YAML/POM files where URL-like strings +# are namespace declarations (http://maven.apache.org/POM/4.0.0 etc.). if [ "$LANGUAGE" = "python" ]; then - ignore_files='^(tests?/|mocks?/|docs?/|.*/spec/|.*/constants\.py|.*/user-guide\.md|README\.md)' + ignore_files='^(tests?/|mocks?/|docs?/|.*/spec/|.*/constants\.py|.*/user-guide\.md|README\.md|.*\.md$|.*\.ya?ml$|.*\.xml$|.*\.properties$|.*pom\.xml$)' else - ignore_files='^(src/test/|mocks?/|docs?/|.*Constants\.java|.*/constants/|.*/user-guide\.md|README\.md)' + ignore_files='^(src/test/|mocks?/|docs?/|.*Constants\.java|.*/constants/|.*/user-guide\.md|README\.md|.*\.md$|.*\.ya?ml$|.*\.xml$|.*\.properties$|.*pom\.xml$)' fi echo "$diff_content" | awk ' @@ -43,30 +47,35 @@ echo "$diff_content" | awk ' [ -z "$url" ] && continue # allow-list: only IANA-reserved test/example TLDs and localhost # `.example` must be the terminal label (RFC 2606) — anchor at path/port/end. + # FP-B-01: also allowlist standard XML/POM/W3C namespace URLs which appear + # as identifiers in build files, not as network endpoints. if echo "$url" | grep -qE '^https?://(localhost|127\.0\.0\.1|example\.(com|org|net)|[^/]+\.example(/|:|$)|reserved\.)'; then continue fi - emit_finding "HC-01" "BLOCK" "$file" "$line_num" "Hardcoded URL '$url' in implementation — externalize to config" "" >> "$findings" + if echo "$url" | grep -qE '^https?://(maven\.apache\.org/POM/|www\.w3\.org/[0-9]+/XMLSchema|schemas\.xmlsoap\.org/)'; then + continue + fi + emit_finding_if_touched "HC-01" "BLOCK" "$file" "$line_num" "Hardcoded URL '$url' in implementation — externalize to config" "" >> "$findings" break # only one finding per line to avoid duplicate reports done < <(echo "$content" | grep -oE 'https?://[A-Za-z0-9][A-Za-z0-9._~:/?#@!$&*+,;=%-]*' || true) # HC-02: Authorization Bearer if echo "$content" | grep -qE 'Authorization[[:space:]]*:[[:space:]]*Bearer[[:space:]]+[A-Za-z0-9]'; then - emit_finding "HC-02" "BLOCK" "$file" "$line_num" "Hardcoded Authorization header value" "" >> "$findings" + emit_finding_if_touched "HC-02" "BLOCK" "$file" "$line_num" "Hardcoded Authorization header value" "" >> "$findings" fi # HC-04: direct os.environ / System.getenv if [ "$LANGUAGE" = "python" ]; then if echo "$content" | grep -qE 'os\.environ\["[A-Z]'; then - emit_finding "HC-04" "FLAG" "$file" "$line_num" "Direct os.environ access — prefer secret_resolver / config layer" "" >> "$findings" + emit_finding_if_touched "HC-04" "FLAG" "$file" "$line_num" "Direct os.environ access — prefer secret_resolver / config layer" "" >> "$findings" fi else if echo "$content" | grep -qE 'System\.getenv\('; then - emit_finding "HC-04" "FLAG" "$file" "$line_num" "Direct System.getenv() — prefer SecretResolver" "" >> "$findings" + emit_finding_if_touched "HC-04" "FLAG" "$file" "$line_num" "Direct System.getenv() — prefer SecretResolver" "" >> "$findings" fi fi # HC-06: hardcoded timeout numeric literal # Use word boundary via (^|[^A-Za-z0-9_]) so 'default_timeout' or 'my_timeout' don't match if echo "$content" | grep -qiE '(^|[^A-Za-z0-9_])(timeout|retries|max_retries)[[:space:]]*=[[:space:]]*[0-9]+'; then - emit_finding "HC-06" "FLAG" "$file" "$line_num" "Hardcoded timeout/retry number — externalize to config" "" >> "$findings" + emit_finding_if_touched "HC-06" "FLAG" "$file" "$line_num" "Hardcoded timeout/retry number — externalize to config" "" >> "$findings" fi done diff --git a/.claude/scripts/check-http-hygiene.sh b/.claude/scripts/check-http-hygiene.sh index d565cb30..06763283 100755 --- a/.claude/scripts/check-http-hygiene.sh +++ b/.claude/scripts/check-http-hygiene.sh @@ -4,6 +4,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib/json-emit.sh" +source "$SCRIPT_DIR/lib/hunk-filter.sh" source "$SCRIPT_DIR/lib/skill-self-skip.sh" LANGUAGE="${LANGUAGE:-python}" @@ -24,10 +25,12 @@ echo "$diff_content" | awk ' ' | while IFS=$'\t' read -r file line_num content; do [ -z "$file" ] && continue if [ "$(is_skill_file "$file")" = "true" ]; then continue; fi - # only for impl files (not tests/mocks) - if echo "$file" | grep -qE '^(tests?/|mocks?/)'; then continue; fi + # only for impl files (not tests/mocks/docs/examples/markdown) + # FP-B-02: HTTP-01 must not fire on markdown code fences. + if echo "$file" | grep -qE '^(tests?/|mocks?/|docs?/|examples?/)'; then continue; fi + if echo "$file" | grep -qiE '\.(md|rst|txt)$'; then continue; fi if echo "$content" | grep -qE '(requests|httpx)\.(Session|AsyncClient)\(\)'; then - emit_finding "HTTP-01" "FLAG" "$file" "$line_num" \ + emit_finding_if_touched "HTTP-01" "FLAG" "$file" "$line_num" \ "HTTP session created per invocation — prefer single instance in __init__" "" >> "$findings" fi done diff --git a/.claude/scripts/check-patterns.sh b/.claude/scripts/check-patterns.sh index c3cc7b83..1e6a9165 100755 --- a/.claude/scripts/check-patterns.sh +++ b/.claude/scripts/check-patterns.sh @@ -5,6 +5,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib/json-emit.sh" source "$SCRIPT_DIR/lib/predicates.sh" +source "$SCRIPT_DIR/lib/hunk-filter.sh" +source "$SCRIPT_DIR/lib/baseline.sh" +source "$SCRIPT_DIR/lib/peer-consistency.sh" LANGUAGE="${LANGUAGE:-python}" REPO_ROOT="${REPO_ROOT:-.}" @@ -33,20 +36,31 @@ while IFS= read -r mod; do [ -d "$mod_dir" ] || continue - # PY-PT-01 / JV-PT-01: factory exists — only fire on client modules + # PY-PT-01 / JV-PT-01: peer-consistency check (FP-G-01). + # Previously: BLOCK if client module missing create_client()/ClientFactory. + # Empirically wrong — only `destination` follows that shape. Now: FLAG only + # when the module diverges from an element that ≥80% of peers adopt. + # Tier: FLAG (never BLOCK). shape=$(module_shape "$mod_dir") if [ "$shape" = "client" ]; then - if [ "$LANGUAGE" = "python" ]; then - if ! grep -rqE '^def create_[a-z_]*client\(' "$mod_dir" 2>/dev/null; then - emit_finding "PY-PT-01" "BLOCK" "src/sap_cloud_sdk/$mod/" 1 \ - "Client module '$mod' missing create_client() factory function" "" >> "$findings" + for element in factory client config user-guide.md exceptions py.typed; do + # py.typed only applies to Python + if [ "$element" = "py.typed" ] && [ "$LANGUAGE" != "python" ]; then continue; fi + # exceptions is checked separately below with AST for accuracy + if [ "$element" = "exceptions" ]; then continue; fi + if should_flag_peer_divergence "$mod_dir" "$element" "$LANGUAGE" "0.80"; then + if [ "$LANGUAGE" = "python" ]; then + rule_id="PY-PT-01" + rel="src/sap_cloud_sdk/$mod/" + else + rule_id="JV-PT-01" + rel="src/main/java/com/sap/cloud/sdk/$mod/" + fi + emit_finding "$rule_id" "FLAG" "$rel" 1 \ + "Module '$mod' diverges from peer convention: missing '$element' (≥80% of peer modules have it)" \ + "Consider adding $element for consistency with sibling modules" >> "$findings" fi - else - if ! grep -rqE 'class [A-Z][A-Za-z]*ClientFactory' "$mod_dir" 2>/dev/null; then - emit_finding "JV-PT-01" "BLOCK" "src/main/java/com/sap/cloud/sdk/$mod/" 1 \ - "Client module '$mod' missing ClientFactory class" "" >> "$findings" - fi - fi + done fi # PY-PT-03: py.typed marker @@ -58,10 +72,12 @@ while IFS= read -r mod; do fi # PY-PT-04 / JV-PT-05: module-specific exceptions + # FP-C-02: pass if module has ANY class subclassing Exception (or *Error/*Exception), + # regardless of whether it lives in exceptions.py or __init__.py or elsewhere. if [ "$LANGUAGE" = "python" ]; then - if [ ! -f "$mod_dir/exceptions.py" ]; then + if ! python3 "$SCRIPT_DIR/lib/ast_python_checks.py" pt-04 "$mod_dir" 2>/dev/null; then emit_finding "PY-PT-04" "FLAG" "src/sap_cloud_sdk/$mod/exceptions.py" 1 \ - "Module lacks exceptions.py — module-specific exception hierarchy recommended" "" >> "$findings" + "Module lacks Exception subclasses — define a module-specific exception hierarchy (in exceptions.py or __init__.py)" "" >> "$findings" fi else if [ ! -d "$mod_dir/exceptions" ]; then @@ -73,11 +89,28 @@ while IFS= read -r mod; do done <<< "$modules" # PY-PT-08 via AST on changed Python files +# FP-A-01: filter by ADDED_LINES_FILE (only fire on functions declared on lines the PR touched) +# FP-E-01: consult line-level baseline for pre-existing PT-08 debt if [ "$LANGUAGE" = "python" ]; then changed_py=$(echo "$diff_content" | grep -oE '^\+\+\+ b/src/sap_cloud_sdk/.*\.py' | sed 's|^+++ b/||' | grep -v '__pycache__' | sort -u) if [ -n "$changed_py" ]; then + raw_pt08=$(mktemp); trap 'rm -f "$raw_pt08"' EXIT # shellcheck disable=SC2086 - python3 "$SCRIPT_DIR/lib/ast_python_checks.py" pt-08 $changed_py 2>/dev/null >> "$findings" || true + python3 "$SCRIPT_DIR/lib/ast_python_checks.py" pt-08 $changed_py 2>/dev/null > "$raw_pt08" || true + # Filter: keep only findings on lines touched by this PR AND not in baseline + while IFS= read -r finding; do + [ -z "$finding" ] && continue + f=$(echo "$finding" | python3 -c "import json,sys;o=json.loads(sys.stdin.read());print(o.get('file',''))") + ln=$(echo "$finding" | python3 -c "import json,sys;o=json.loads(sys.stdin.read());print(o.get('line',0))") + [ -z "$f" ] && continue + # Baseline check first (bypasses hunk filter — always suppressed) + if is_in_line_baseline "PY-PT-08" "$f" "$ln"; then continue; fi + # Hunk filter + if is_line_touched "$f" "$ln"; then + echo "$finding" >> "$findings" + fi + done < "$raw_pt08" + rm -f "$raw_pt08" fi fi diff --git a/.claude/scripts/check-secrets.sh b/.claude/scripts/check-secrets.sh index 2fd697fb..a96abd33 100755 --- a/.claude/scripts/check-secrets.sh +++ b/.claude/scripts/check-secrets.sh @@ -6,6 +6,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/json-emit.sh source "$SCRIPT_DIR/lib/json-emit.sh" +# shellcheck source=lib/hunk-filter.sh +source "$SCRIPT_DIR/lib/hunk-filter.sh" # shellcheck source=lib/skill-self-skip.sh source "$SCRIPT_DIR/lib/skill-self-skip.sh" diff --git a/.claude/scripts/check-telemetry.sh b/.claude/scripts/check-telemetry.sh index b8511850..53175f2f 100755 --- a/.claude/scripts/check-telemetry.sh +++ b/.claude/scripts/check-telemetry.sh @@ -7,6 +7,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/json-emit.sh source "$SCRIPT_DIR/lib/json-emit.sh" +# shellcheck source=lib/hunk-filter.sh +source "$SCRIPT_DIR/lib/hunk-filter.sh" # shellcheck source=lib/skill-self-skip.sh source "$SCRIPT_DIR/lib/skill-self-skip.sh" @@ -54,9 +56,22 @@ else fi # PY-TEL-02: For each changed client file, run AST check +# FP-A-01: filter by hunk attribution — a client method predates the PR unless +# it lives on a line the PR added. if [ "$LANGUAGE" = "python" ] && [ -n "$client_files" ]; then + raw_tel=$(mktemp); trap 'rm -f "$raw_tel"' EXIT # shellcheck disable=SC2086 - python3 "$SCRIPT_DIR/lib/ast_python_checks.py" tel-02 $client_files 2>/dev/null >> "$findings" || true + python3 "$SCRIPT_DIR/lib/ast_python_checks.py" tel-02 $client_files 2>/dev/null > "$raw_tel" || true + while IFS= read -r finding; do + [ -z "$finding" ] && continue + f=$(echo "$finding" | python3 -c "import json,sys;o=json.loads(sys.stdin.read());print(o.get('file',''))") + ln=$(echo "$finding" | python3 -c "import json,sys;o=json.loads(sys.stdin.read());print(o.get('line',0))") + [ -z "$f" ] && continue + if is_line_touched "$f" "$ln"; then + echo "$finding" >> "$findings" + fi + done < "$raw_tel" + rm -f "$raw_tel" fi # JV-TEL-02: For Java, grep-based check (executeWithTelemetry wrap around methods) @@ -72,7 +87,7 @@ if [ "$LANGUAGE" = "java" ] && [ -n "$client_files" ]; then end_line=$((line_num + 20)) body=$(sed -n "${line_num},${end_line}p" "$full_path") if ! echo "$body" | grep -q "executeWithTelemetry"; then - emit_finding "JV-TEL-02" "BLOCK" "$f" "$line_num" \ + emit_finding_if_touched "JV-TEL-02" "BLOCK" "$f" "$line_num" \ "Public method lacks Telemetry.executeWithTelemetry wrap" "" >> "$findings" fi done < <(grep -nE '^[[:space:]]*public [A-Za-z<>]+ [a-z][a-zA-Z0-9]+\(' "$full_path" 2>/dev/null | grep -v 'public class\|public interface\|public enum' || true) diff --git a/.claude/scripts/lib/ast_python_checks.py b/.claude/scripts/lib/ast_python_checks.py index 57172714..7fe15eff 100755 --- a/.claude/scripts/lib/ast_python_checks.py +++ b/.claude/scripts/lib/ast_python_checks.py @@ -212,22 +212,38 @@ def check_pt_08(path: str, tree: ast.Module) -> None: def check_con_01( path: str, tree: ast.Module, threshold: int = 3, min_len: int = 4 ) -> None: + # FP-D-01: skip generated/model files where schema keys legitimately repeat. + GENERATED_PATTERNS = ("_models.py", "_generated.py") + GENERATED_API_SUFFIX = "_api.py" + fname = Path(path).name + if fname.endswith(GENERATED_PATTERNS): + return + # `__api.py` (starts with underscore, ends with _api.py) → generated OpenAPI client + if fname.startswith("_") and fname.endswith(GENERATED_API_SUFFIX): + return # Skip prefixes: URLs, test/example placeholders, SPDX/doc markers. # Must be exact URL scheme match — not `httpx` or `http_pool`. URL_PREFIXES = ("http://", "https://", "ftp://", "sftp://", "ssh://", "file://") SKIP_PREFIXES = ("test-", "SPDX", "@example") + # FP-D-01: minimum length 4 (was 4; enforce hard floor of 3 per plan) + effective_min_len = max(min_len, 3) + # FP-D-01: per-file cap + MAX_PER_FILE = 3 counts: dict[str, list[int]] = {} for node in ast.walk(tree): if isinstance(node, ast.Constant) and isinstance(node.value, str): v = node.value - if len(v) < min_len: + if len(v) < effective_min_len: continue if v.startswith(URL_PREFIXES): continue if v.startswith(SKIP_PREFIXES): continue counts.setdefault(v, []).append(node.lineno) + emitted = 0 for literal, lines in counts.items(): + if emitted >= MAX_PER_FILE: + break if len(lines) >= threshold: emit( "PY-CON-01", @@ -237,6 +253,44 @@ def check_con_01( f"String literal {literal!r} appears {len(lines)}× — extract module-level constant", suggestion=f"e.g., _CONSTANT_NAME = {literal!r}", ) + emitted += 1 + + +# ---------- PY-PT-04: module has any Exception subclass (AST-based) ---------- + + +def check_pt_04(module_dir: str) -> bool: + """FP-C-02: return True if any .py file in the module directory (recursive) + defines a class subclassing Exception (directly or via a name ending in + 'Error' / 'Exception'). Callers can use this as a soft check before + emitting PY-PT-04. + """ + p = Path(module_dir) + if not p.is_dir(): + return False + for py in p.rglob("*.py"): + if "__pycache__" in py.parts: + continue + tree = parse_file(str(py)) + if tree is None: + continue + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + for base in node.bases: + base_name = None + if isinstance(base, ast.Name): + base_name = base.id + elif isinstance(base, ast.Attribute): + base_name = base.attr + if not base_name: + continue + if base_name == "Exception" or base_name == "BaseException": + return True + # accept anything ending in Error/Exception as an exception hierarchy + if base_name.endswith("Error") or base_name.endswith("Exception"): + return True + return False # ---------- PY-PT-01: create_client factory exists ---------- @@ -384,6 +438,12 @@ def main(argv: list[str]) -> int: check_name = argv[1] files = argv[2:] + # FP-C-02: pt-04 has a different signature (module dir, not files) and + # returns a boolean via exit code (0 = has exceptions, 1 = none found). + if check_name == "pt-04": + module_dir = files[0] + return 0 if check_pt_04(module_dir) else 1 + if check_name not in CHECKS: print(f"ERROR: unknown check {check_name}", file=sys.stderr) return 2 diff --git a/.claude/scripts/lib/baseline.sh b/.claude/scripts/lib/baseline.sh index 181349eb..bf0434db 100755 --- a/.claude/scripts/lib/baseline.sh +++ b/.claude/scripts/lib/baseline.sh @@ -76,7 +76,88 @@ apply_baseline_to_report() { '.findings = $kept | .summary.suppressed_by_baseline = $suppressed' } -# When sourced, expose functions. When executed, run apply_baseline_to_report. +# FP-E-01: line-level baseline lookup. +# is_in_line_baseline [baseline_file] +# Returns 0 if the (rule,file,line) triple is present in baseline.json under +# `.line_baseline[]`. baseline.json extension: +# { ..., "line_baseline": [ {"rule":"PY-PT-08","file":"src/x/y.py","line":81}, ... ] } +is_in_line_baseline() { + local rule="$1" file="$2" line="$3" + local baseline_file="${4:-${BASELINE_FILE:-${REPO_ROOT:-.}/.claude/config/baseline.json}}" + [ -f "$baseline_file" ] || return 1 + # Cheap match against the flat JSON — avoids paying for `jq` per finding. + # Line MUST be terminated by non-digit (comma or }) so 81 doesn't match 812. + grep -qE "\"rule\"[[:space:]]*:[[:space:]]*\"${rule}\"[[:space:]]*,[[:space:]]*\"file\"[[:space:]]*:[[:space:]]*\"${file}\"[[:space:]]*,[[:space:]]*\"line\"[[:space:]]*:[[:space:]]*${line}[^0-9]" "$baseline_file" 2>/dev/null +} + +# bootstrap_pt_08 +# Walk the entire codebase, run PY-PT-08 detector, append each hit to +# baseline.json under `line_baseline`. Documented usage: +# bash .claude/scripts/lib/baseline.sh bootstrap PY-PT-08 +# NOT run automatically — humans must call it to snapshot existing tech debt. +bootstrap_pt_08() { + local repo_root="${1:-.}" + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + local baseline_dir="$repo_root/.claude/config" + local baseline="$baseline_dir/baseline.json" + mkdir -p "$baseline_dir" + [ -f "$baseline" ] || echo '{}' > "$baseline" + + # Collect all .py files under src/, run PT-08 detection, merge into baseline. + local py_files + py_files=$(find "$repo_root/src" -name '*.py' -not -path '*/__pycache__/*' 2>/dev/null | tr '\n' ' ') + [ -z "$py_files" ] && { echo "no python files under $repo_root/src" >&2; return 1; } + + # shellcheck disable=SC2086 + local hits + hits=$(python3 "$script_dir/ast_python_checks.py" pt-08 $py_files 2>/dev/null || true) + + BASELINE_JSON="$baseline" HITS="$hits" python3 -c " +import json, os, sys +path = os.environ['BASELINE_JSON'] +hits_raw = os.environ.get('HITS', '') +try: + with open(path) as f: doc = json.load(f) +except Exception: doc = {} +lb = doc.setdefault('line_baseline', []) +seen = {(e.get('rule'), e.get('file'), e.get('line')) for e in lb} +added = 0 +for line in hits_raw.splitlines(): + line = line.strip() + if not line: continue + try: + obj = json.loads(line) + except Exception: + continue + key = (obj.get('rule'), obj.get('file'), obj.get('line')) + if None in key: continue + if key in seen: continue + lb.append({'rule': obj['rule'], 'file': obj['file'], 'line': obj['line']}) + seen.add(key) + added += 1 +with open(path, 'w') as f: + json.dump(doc, f, indent=2) +print(f'baseline bootstrapped: +{added} PY-PT-08 entries (total {len(lb)})', file=sys.stderr) +" +} + +# When sourced, expose functions. When executed, dispatch subcommand. if [ "${BASH_SOURCE[0]}" = "${0}" ]; then - apply_baseline_to_report "$@" + case "${1:-}" in + bootstrap) + shift + rule="${1:-}"; shift || true + case "$rule" in + PY-PT-08) bootstrap_pt_08 "${1:-.}" ;; + *) echo "unsupported bootstrap rule: $rule (only PY-PT-08)" >&2; exit 2 ;; + esac + ;; + is_in_line_baseline) + shift; is_in_line_baseline "$@" + ;; + *) + apply_baseline_to_report "$@" + ;; + esac fi diff --git a/.claude/scripts/lib/hunk-filter.sh b/.claude/scripts/lib/hunk-filter.sh new file mode 100755 index 00000000..70930a57 --- /dev/null +++ b/.claude/scripts/lib/hunk-filter.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# lib/hunk-filter.sh — restrict findings to lines actually touched by the PR diff. +# +# The rule engine's contract (per 05-VALIDATION-V2.md §T-4) is that findings +# only fire on lines in the PR's added-lines set. Without this filter every +# check emits noise for pre-existing code. See docs/plans 09-FP-REMEDIATION +# §Pattern A for the empirical justification (84% FP rate reduced to <15%). +# +# The added-lines file is produced by orchestrate.sh from +# `lib/diff-added-lines.sh` and exported as $ADDED_LINES_FILE. Format: +# path/to/file.py:42 +# path/to/file.py:43 +# ... +# +# is_line_touched -> exit 0 if touched, 1 if not +# is_range_touched -> exit 0 if any line in range touched +# +# Both helpers return 0 (permissive) when $ADDED_LINES_FILE is unset or empty +# so the checks continue to work when invoked standalone (e.g., bats tests). + +set -uo pipefail + +is_line_touched() { + local file="$1" line="$2" + # Backward-compat: without the added-lines context, allow everything. + [ -n "${ADDED_LINES_FILE:-}" ] || return 0 + [ -f "${ADDED_LINES_FILE:-}" ] || return 0 + [ -s "${ADDED_LINES_FILE:-}" ] || return 0 + grep -Fxq "${file}:${line}" "${ADDED_LINES_FILE}" +} + +is_range_touched() { + local file="$1" start="$2" end="$3" + [ -n "${ADDED_LINES_FILE:-}" ] || return 0 + [ -f "${ADDED_LINES_FILE:-}" ] || return 0 + [ -s "${ADDED_LINES_FILE:-}" ] || return 0 + # Cheap path: grep any line in [start, end] for the file + local ln + for ln in $(seq "$start" "$end"); do + if grep -Fxq "${file}:${ln}" "${ADDED_LINES_FILE}"; then + return 0 + fi + done + return 1 +} + +# is_meta_finding -> exit 0 if this is a PR-metadata finding (PR_BODY, +# COMMIT:*, or ".") that should bypass hunk attribution entirely. +is_meta_finding() { + local file="$1" + case "$file" in + PR_BODY|.|""|COMMIT:*|PR_METADATA) return 0 ;; + *) return 1 ;; + esac +} + +# Convenience wrapper: emit only if the finding is either metadata or touched. +# Signature matches emit_finding but adds hunk filtering. +# emit_finding_if_touched +emit_finding_if_touched() { + local rule="$1" sev="$2" file="$3" line="$4" msg="$5" sugg="$6" + if is_meta_finding "$file"; then + emit_finding "$rule" "$sev" "$file" "$line" "$msg" "$sugg" + return + fi + if is_line_touched "$file" "$line"; then + emit_finding "$rule" "$sev" "$file" "$line" "$msg" "$sugg" + fi +} + +# CLI entry point (used by bats tests) +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + case "${1:-}" in + is_line_touched) shift; is_line_touched "$@" ;; + is_range_touched) shift; is_range_touched "$@" ;; + is_meta_finding) shift; is_meta_finding "$@" ;; + *) echo "Usage: hunk-filter.sh {is_line_touched|is_range_touched|is_meta_finding} args" >&2; exit 2 ;; + esac +fi diff --git a/.claude/scripts/lib/peer-consistency.sh b/.claude/scripts/lib/peer-consistency.sh new file mode 100755 index 00000000..76fd4ba8 --- /dev/null +++ b/.claude/scripts/lib/peer-consistency.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# lib/peer-consistency.sh — compute peer-adoption fractions and fire only when +# a new module diverges from a widely-adopted element (>=80%). +# +# The v2 rule engine originally hard-coded "every client module MUST have a +# Factory" — but empirical review of the JV corpus showed only `destination` +# has a Factory; `objectstore`, `agentgateway`, `adms`, `aicore` are all +# valid module designs without one. Encoding one module's shape as universal +# law is FP-G-01 (see docs/plans 09-FP-REMEDIATION §Pattern G). +# +# This helper answers: "of all existing modules under src/**//, what +# fraction have ?" A rule can then say `if fraction >= 0.8 and new +# module lacks it, emit FLAG`. +# +# All emissions from peer-consistency checks are FLAG tier, never BLOCK. +# +# API: +# peer_modules → prints one module name/line +# peer_element_fraction +# → prints "count/total" and returns 0. `element` is one of: +# user-guide.md, exceptions, factory, client, config, py.typed +# should_flag_peer_divergence +# → returns 0 if the module lacks the element AND fraction >= 0.8 + +set -uo pipefail + +peer_modules() { + local repo_root="${1:-.}" lang="${2:-python}" + if [ "$lang" = "python" ]; then + find "$repo_root/src/sap_cloud_sdk" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | \ + awk -F/ '{print $NF}' | grep -vE '^(__pycache__|core)$' | sort -u + else + find "$repo_root/src/main/java/com/sap/cloud/sdk" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | \ + awk -F/ '{print $NF}' | grep -vE '^(core)$' | sort -u + fi +} + +# has_element +# Returns 0 if the module dir contains the element. +# Elements: +# user-guide.md a user-guide.md at module root +# exceptions exceptions.py OR any Exception subclass in module (python) +# exceptions/ dir (java) +# factory create_*_client() function or *ClientFactory class +# client Client class (python: client.py; java: *Client.java) +# config config.py or *Config.java +# py.typed py.typed marker (python only) +has_element() { + local mod_dir="$1" el="$2" lang="${3:-python}" + [ -d "$mod_dir" ] || return 1 + case "$el" in + user-guide.md) + [ -f "$mod_dir/user-guide.md" ] + ;; + exceptions) + if [ "$lang" = "python" ]; then + [ -f "$mod_dir/exceptions.py" ] && return 0 + # Fallback: any Exception subclass anywhere in module tree + grep -rEIlq 'class [A-Z][A-Za-z0-9_]+\((Base)?Exception|[A-Z][A-Za-z]+(Error|Exception))\)' \ + "$mod_dir" 2>/dev/null + else + [ -d "$mod_dir/exceptions" ] + fi + ;; + factory) + if [ "$lang" = "python" ]; then + grep -rEIlq '^def create_[a-z_]*client\(' "$mod_dir" 2>/dev/null + else + grep -rEIlq 'class [A-Z][A-Za-z]*ClientFactory' "$mod_dir" 2>/dev/null + fi + ;; + client) + if [ "$lang" = "python" ]; then + [ -f "$mod_dir/client.py" ] && return 0 + grep -rEIlq '^class [A-Z][A-Za-z0-9_]+Client\b' "$mod_dir" 2>/dev/null + else + find "$mod_dir" -name '*Client.java' 2>/dev/null | grep -q . + fi + ;; + config) + if [ "$lang" = "python" ]; then + [ -f "$mod_dir/config.py" ] + else + find "$mod_dir" -name '*Config.java' 2>/dev/null | grep -q . + fi + ;; + py.typed) + [ -f "$mod_dir/py.typed" ] + ;; + *) + return 2 + ;; + esac +} + +# peer_element_fraction +# Prints "adopted total fraction" where fraction is a float 0.0-1.0. +peer_element_fraction() { + local repo_root="$1" lang="$2" el="$3" + local mod adopted=0 total=0 mod_dir + while IFS= read -r mod; do + [ -z "$mod" ] && continue + if [ "$lang" = "python" ]; then + mod_dir="$repo_root/src/sap_cloud_sdk/$mod" + else + mod_dir="$repo_root/src/main/java/com/sap/cloud/sdk/$mod" + fi + total=$((total + 1)) + if has_element "$mod_dir" "$el" "$lang"; then + adopted=$((adopted + 1)) + fi + done < <(peer_modules "$repo_root" "$lang") + if [ "$total" -eq 0 ]; then + echo "0 0 0.0" + return + fi + # bash arithmetic can't do floats — compute via awk + local frac + frac=$(awk -v a="$adopted" -v t="$total" 'BEGIN{printf "%.3f", a/t}') + echo "$adopted $total $frac" +} + +# should_flag_peer_divergence +# Returns 0 (should flag) if: +# - module lacks the element, AND +# - peer adoption fraction (excluding this module) >= threshold (default 0.80) +# The caller supplies the module dir, element name, and the repo/lang context. +should_flag_peer_divergence() { + local mod_dir="$1" el="$2" lang="${3:-python}" threshold="${4:-0.80}" + # Guard: if module has the element already, no flag. + if has_element "$mod_dir" "$el" "$lang"; then return 1; fi + # Compute fraction across peers, EXCLUDING the module itself. + local repo_root this_mod + this_mod="$(basename "$mod_dir")" + if [ "$lang" = "python" ]; then + repo_root="$(cd "$mod_dir/../../.." && pwd)" + else + repo_root="$(cd "$mod_dir/../../../../../../.." && pwd)" + fi + local mod adopted=0 total=0 peer_dir + while IFS= read -r mod; do + [ -z "$mod" ] && continue + # Skip the module under review — we only care about peer adoption. + [ "$mod" = "$this_mod" ] && continue + if [ "$lang" = "python" ]; then + peer_dir="$repo_root/src/sap_cloud_sdk/$mod" + else + peer_dir="$repo_root/src/main/java/com/sap/cloud/sdk/$mod" + fi + total=$((total + 1)) + if has_element "$peer_dir" "$el" "$lang"; then + adopted=$((adopted + 1)) + fi + done < <(peer_modules "$repo_root" "$lang") + # need at least 2 peers to draw a "pattern" + [ "$total" -ge 2 ] || return 1 + local frac + frac=$(awk -v a="$adopted" -v t="$total" 'BEGIN{printf "%.3f", a/t}') + awk -v f="$frac" -v t="$threshold" 'BEGIN{exit !(f+0 >= t+0)}' +} + +# CLI dispatch (only when executed, not when sourced) +if [ "${BASH_SOURCE[0]:-}" = "${0:-}" ] && [ -n "${BASH_SOURCE[0]:-}" ]; then + case "${1:-}" in + peer_modules) shift; peer_modules "$@" ;; + has_element) shift; has_element "$@" ;; + peer_element_fraction) shift; peer_element_fraction "$@" ;; + should_flag_peer_divergence) shift; should_flag_peer_divergence "$@" ;; + *) echo "Usage: peer-consistency.sh {peer_modules|has_element|peer_element_fraction|should_flag_peer_divergence} ..." >&2; exit 2 ;; + esac +fi diff --git a/tests/sdk-review/test_fp_remediation.bats b/tests/sdk-review/test_fp_remediation.bats new file mode 100644 index 00000000..f91ce12c --- /dev/null +++ b/tests/sdk-review/test_fp_remediation.bats @@ -0,0 +1,319 @@ +#!/usr/bin/env bats +# test_fp_remediation.bats — regression tests for the 8 FP-* fixes catalogued +# in docs/plans 09-FP-REMEDIATION.md §2. Each test pins ONE fix so a future +# regression is caught immediately. +# +# Runs standalone (uses ADDED_LINES_FILE to feed the hunk filter). If a check +# script or lib helper isn't present in the current batch, the test is skipped. + +setup() { + SCRIPT_DIR="$BATS_TEST_DIRNAME/../../.claude/scripts" + FIXTURES="$BATS_TEST_DIRNAME/fixtures" + export LANGUAGE=python + export REPO_ROOT="$BATS_TEST_DIRNAME/../.." + export CONFIG_DIR="$REPO_ROOT/.claude/config" +} + +# ------------------------------------------------------------ +# FP-A-01 — hunk attribution enforced +# ------------------------------------------------------------ + +@test "FP-A-01: is_line_touched respects ADDED_LINES_FILE" { + [ -f "$SCRIPT_DIR/lib/hunk-filter.sh" ] || skip "hunk-filter.sh not in this batch" + tmpd=$(mktemp -d) + cat > "$tmpd/added.txt" <<'EOF' +src/foo.py:5 +src/foo.py:6 +src/foo.py:7 +src/foo.py:8 +src/foo.py:9 +src/foo.py:10 +EOF + # touched + ADDED_LINES_FILE="$tmpd/added.txt" bash "$SCRIPT_DIR/lib/hunk-filter.sh" is_line_touched src/foo.py 7 + # not touched + run env ADDED_LINES_FILE="$tmpd/added.txt" bash "$SCRIPT_DIR/lib/hunk-filter.sh" is_line_touched src/foo.py 100 + [ "$status" -ne 0 ] + rm -rf "$tmpd" +} + +@test "FP-A-01: is_meta_finding treats PR_BODY / COMMIT:* as metadata" { + [ -f "$SCRIPT_DIR/lib/hunk-filter.sh" ] || skip "hunk-filter.sh not in this batch" + bash "$SCRIPT_DIR/lib/hunk-filter.sh" is_meta_finding PR_BODY + bash "$SCRIPT_DIR/lib/hunk-filter.sh" is_meta_finding COMMIT:abc123 + run bash "$SCRIPT_DIR/lib/hunk-filter.sh" is_meta_finding src/foo.py + [ "$status" -ne 0 ] +} + +# ------------------------------------------------------------ +# FP-B-01 — HC-01 ignores POM/XML namespaces +# ------------------------------------------------------------ + +@test "FP-B-01: HC-01 does not fire on pom.xml POM namespace" { + [ -f "$SCRIPT_DIR/check-hardcode.sh" ] || skip "check-hardcode.sh not in this batch" + tmpd=$(mktemp -d) + cat > "$tmpd/diff" <<'EOF' +diff --git a/pom.xml b/pom.xml +new file mode 100644 +--- /dev/null ++++ b/pom.xml +@@ -0,0 +1,5 @@ ++ ++ ++ 4.0.0 ++ +EOF + bash "$SCRIPT_DIR/lib/diff-added-lines.sh" < "$tmpd/diff" > "$tmpd/added.txt" + result=$(LANGUAGE=java ADDED_LINES_FILE="$tmpd/added.txt" DIFF_FILE="$tmpd/diff" bash "$SCRIPT_DIR/check-hardcode.sh") + hc01_count=$(echo "$result" | jq '[.findings[] | select(.rule=="HC-01")] | length') + [ "$hc01_count" = "0" ] + rm -rf "$tmpd" +} + +# ------------------------------------------------------------ +# FP-B-02 — HTTP-01 ignores markdown code fences +# ------------------------------------------------------------ + +@test "FP-B-02: HTTP-01 does not fire on .md files" { + [ -f "$SCRIPT_DIR/check-http-hygiene.sh" ] || skip "check-http-hygiene.sh not in this batch" + tmpd=$(mktemp -d) + cat > "$tmpd/diff" <<'EOF' +diff --git a/docs/user-guide.md b/docs/user-guide.md +new file mode 100644 +--- /dev/null ++++ b/docs/user-guide.md +@@ -0,0 +1,4 @@ ++# HTTP client usage ++```python ++client = httpx.Client() ++``` +EOF + bash "$SCRIPT_DIR/lib/diff-added-lines.sh" < "$tmpd/diff" > "$tmpd/added.txt" + result=$(ADDED_LINES_FILE="$tmpd/added.txt" DIFF_FILE="$tmpd/diff" bash "$SCRIPT_DIR/check-http-hygiene.sh") + http01_count=$(echo "$result" | jq '[.findings[] | select(.rule=="HTTP-01")] | length') + [ "$http01_count" = "0" ] + rm -rf "$tmpd" +} + +# ------------------------------------------------------------ +# FP-C-01 — BDD-01 accepts any *.feature file, not just .feature +# ------------------------------------------------------------ + +@test "FP-C-01: BDD-01 accepts scenarios.feature when module .feature is absent" { + [ -f "$SCRIPT_DIR/check-bdd.sh" ] || skip "check-bdd.sh not in this batch" + tmpd=$(mktemp -d) + # Simulate a new module `foo` with source file and a feature file NOT + # named foo.feature — BDD-01 must PASS (glob-based check). + mkdir -p "$tmpd/src/sap_cloud_sdk/foo" + echo "x = 1" > "$tmpd/src/sap_cloud_sdk/foo/client.py" + mkdir -p "$tmpd/tests/foo/integration" + echo "Feature: something else" > "$tmpd/tests/foo/integration/scenarios.feature" + cat > "$tmpd/diff" <<'EOF' +diff --git a/src/sap_cloud_sdk/foo/client.py b/src/sap_cloud_sdk/foo/client.py +new file mode 100644 +--- /dev/null ++++ b/src/sap_cloud_sdk/foo/client.py +@@ -0,0 +1,1 @@ ++x = 1 +EOF + result=$(REPO_ROOT="$tmpd" DIFF_FILE="$tmpd/diff" LANGUAGE=python bash "$SCRIPT_DIR/check-bdd.sh") + bdd01_count=$(echo "$result" | jq '[.findings[] | select(.rule=="BDD-01")] | length') + [ "$bdd01_count" = "0" ] + rm -rf "$tmpd" +} + +# ------------------------------------------------------------ +# FP-C-02 — PY-PT-04 accepts Exception subclass anywhere in module (AST) +# ------------------------------------------------------------ + +@test "FP-C-02: PY-PT-04 accepts Exception subclass in __init__.py (not exceptions.py)" { + [ -f "$SCRIPT_DIR/lib/ast_python_checks.py" ] || skip "ast_python_checks.py not in this batch" + tmpd=$(mktemp -d) + mkdir -p "$tmpd/foo" + cat > "$tmpd/foo/__init__.py" <<'EOF' +class FooError(Exception): + pass +EOF + # pt-04 helper returns exit 0 if module has any Exception subclass. + python3 "$SCRIPT_DIR/lib/ast_python_checks.py" pt-04 "$tmpd/foo" + # Negative: empty module → exit 1 + mkdir -p "$tmpd/bar" + echo "x = 1" > "$tmpd/bar/__init__.py" + run python3 "$SCRIPT_DIR/lib/ast_python_checks.py" pt-04 "$tmpd/bar" + [ "$status" -ne 0 ] + rm -rf "$tmpd" +} + +# ------------------------------------------------------------ +# FP-D-01 — PY-CON-01 skips generated model files + per-file cap +# ------------------------------------------------------------ + +@test "FP-D-01: PY-CON-01 skips _models.py files entirely" { + [ -f "$SCRIPT_DIR/lib/ast_python_checks.py" ] || skip "ast_python_checks.py not in this batch" + tmpd=$(mktemp -d) + cat > "$tmpd/_models.py" <<'EOF' +class A: x = "value"; y = "value"; z = "value"; w = "value" +class B: x = "value"; y = "value"; z = "value" +EOF + result=$(python3 "$SCRIPT_DIR/lib/ast_python_checks.py" con-01 "$tmpd/_models.py" 2>/dev/null) + # Expect zero findings (file skipped) + [ -z "$result" ] + rm -rf "$tmpd" +} + +@test "FP-D-01: PY-CON-01 caps at 3 findings per file" { + [ -f "$SCRIPT_DIR/lib/ast_python_checks.py" ] || skip "ast_python_checks.py not in this batch" + tmpd=$(mktemp -d) + # Six different repeated literals (each ≥3× and ≥3 chars) — should be capped to 3. + cat > "$tmpd/regular.py" <<'EOF' +a = "foo1234"; b = "foo1234"; c = "foo1234" +d = "bar1234"; e = "bar1234"; f = "bar1234" +g = "baz1234"; h = "baz1234"; i = "baz1234" +j = "qux1234"; k = "qux1234"; l = "qux1234" +m = "quux12"; n = "quux12"; o = "quux12" +p = "corge2"; q = "corge2"; r = "corge2" +EOF + count=$(python3 "$SCRIPT_DIR/lib/ast_python_checks.py" con-01 "$tmpd/regular.py" 2>/dev/null | wc -l | tr -d ' ') + [ "$count" -le 3 ] + rm -rf "$tmpd" +} + +# ------------------------------------------------------------ +# FP-E-01 — PY-PT-08 baseline suppresses pre-existing lines +# ------------------------------------------------------------ + +@test "FP-E-01: is_in_line_baseline matches (rule,file,line) triple" { + [ -f "$SCRIPT_DIR/lib/baseline.sh" ] || skip "baseline.sh not in this batch" + # Skip if this batch's baseline.sh lacks the new function. + grep -q "is_in_line_baseline" "$SCRIPT_DIR/lib/baseline.sh" || skip "baseline.sh lacks is_in_line_baseline" + tmpd=$(mktemp -d) + cat > "$tmpd/baseline.json" <<'EOF' +{ + "line_baseline": [ + {"rule": "PY-PT-08", "file": "src/foo/__init__.py", "line": 81}, + {"rule": "PY-PT-08", "file": "src/foo/__init__.py", "line": 127} + ] +} +EOF + BASELINE_FILE="$tmpd/baseline.json" bash "$SCRIPT_DIR/lib/baseline.sh" is_in_line_baseline PY-PT-08 src/foo/__init__.py 81 + BASELINE_FILE="$tmpd/baseline.json" bash "$SCRIPT_DIR/lib/baseline.sh" is_in_line_baseline PY-PT-08 src/foo/__init__.py 127 + # Not baselined + run env BASELINE_FILE="$tmpd/baseline.json" bash "$SCRIPT_DIR/lib/baseline.sh" is_in_line_baseline PY-PT-08 src/foo/__init__.py 999 + [ "$status" -ne 0 ] + # Prefix-guard: 81 must NOT match 812 + cat > "$tmpd/baseline.json" <<'EOF' +{"line_baseline": [{"rule":"PY-PT-08","file":"src/foo.py","line":81}]} +EOF + run env BASELINE_FILE="$tmpd/baseline.json" bash "$SCRIPT_DIR/lib/baseline.sh" is_in_line_baseline PY-PT-08 src/foo.py 812 + [ "$status" -ne 0 ] + rm -rf "$tmpd" +} + +# ------------------------------------------------------------ +# DEL-01 guardrail — synthetic path is not filtered out +# ------------------------------------------------------------ + +@test "DEL-01: guardrail — synthetic src/ path is treated as metadata by hunk filter" { + [ -f "$SCRIPT_DIR/lib/hunk-filter.sh" ] || skip "hunk-filter.sh not in this batch" + # is_meta_finding returns 0 for special paths. src/ is a real path so we test + # the emit_finding_if_touched path — DEL-01's src/:1 emission bypasses the + # filter because the calling script does NOT wrap it (verified via grep). + ! grep -q "emit_finding_if_touched \"DEL-01\"" "$SCRIPT_DIR/check-deletion-hygiene.sh" + # DEL-01 is called via plain emit_finding — the fix is deliberate. +} + +# ------------------------------------------------------------ +# FP-G-01 — peer-consistency for PT-01 (no more "factory required" law) +# ------------------------------------------------------------ + +@test "FP-G-01: peer_element_fraction returns adopted/total/fraction" { + [ -f "$SCRIPT_DIR/lib/peer-consistency.sh" ] || skip "peer-consistency.sh not in this batch" + tmpd=$(mktemp -d) + mkdir -p "$tmpd/src/sap_cloud_sdk/a" "$tmpd/src/sap_cloud_sdk/b" "$tmpd/src/sap_cloud_sdk/c" + # a and b have user-guide.md; c doesn't + touch "$tmpd/src/sap_cloud_sdk/a/user-guide.md" "$tmpd/src/sap_cloud_sdk/b/user-guide.md" + result=$(bash "$SCRIPT_DIR/lib/peer-consistency.sh" peer_element_fraction "$tmpd" python user-guide.md) + adopted=$(echo "$result" | awk '{print $1}') + total=$(echo "$result" | awk '{print $2}') + [ "$adopted" = "2" ] + [ "$total" = "3" ] + rm -rf "$tmpd" +} + +@test "FP-G-01: PT-01 fires FLAG when new module lacks a >=80% adopted element" { + [ -f "$SCRIPT_DIR/check-patterns.sh" ] || skip "check-patterns.sh not in this batch" + tmpd=$(mktemp -d) + # Peers a, b, c all have user-guide.md (100%). New module `foo` doesn't. + mkdir -p "$tmpd/src/sap_cloud_sdk/"{a,b,c,foo} + for m in a b c; do + touch "$tmpd/src/sap_cloud_sdk/$m/user-guide.md" + touch "$tmpd/src/sap_cloud_sdk/$m/client.py" + echo "def create_${m}_client(): pass" > "$tmpd/src/sap_cloud_sdk/$m/client.py" + done + # foo has client.py but no user-guide.md → should FLAG + echo "class FooClient: pass" > "$tmpd/src/sap_cloud_sdk/foo/client.py" + # Diff creates foo — client shape + cat > "$tmpd/diff" <<'EOF' +diff --git a/src/sap_cloud_sdk/foo/client.py b/src/sap_cloud_sdk/foo/client.py +new file mode 100644 +--- /dev/null ++++ b/src/sap_cloud_sdk/foo/client.py +@@ -0,0 +1,1 @@ ++class FooClient: pass +EOF + result=$(REPO_ROOT="$tmpd" DIFF_FILE="$tmpd/diff" LANGUAGE=python bash "$SCRIPT_DIR/check-patterns.sh" 2>/dev/null) + # PY-PT-01 should fire FLAG (not BLOCK) mentioning user-guide.md + pt01_flag=$(echo "$result" | jq '[.findings[] | select(.rule=="PY-PT-01" and .severity=="FLAG")] | length') + [ "$pt01_flag" -ge 1 ] + pt01_block=$(echo "$result" | jq '[.findings[] | select(.rule=="PY-PT-01" and .severity=="BLOCK")] | length') + [ "$pt01_block" = "0" ] + rm -rf "$tmpd" +} + +@test "FP-G-01: PT-01 does NOT fire when peers <80% adopt the element (factory case)" { + [ -f "$SCRIPT_DIR/check-patterns.sh" ] || skip "check-patterns.sh not in this batch" + tmpd=$(mktemp -d) + # 3 peers: only 1 has create_*_client → 33% adoption of factory element. + mkdir -p "$tmpd/src/sap_cloud_sdk/"{a,b,c,foo} + echo "def create_a_client(): pass" > "$tmpd/src/sap_cloud_sdk/a/client.py" + echo "class BClient: pass" > "$tmpd/src/sap_cloud_sdk/b/client.py" + echo "class CClient: pass" > "$tmpd/src/sap_cloud_sdk/c/client.py" + echo "class FooClient: pass" > "$tmpd/src/sap_cloud_sdk/foo/client.py" + cat > "$tmpd/diff" <<'EOF' +diff --git a/src/sap_cloud_sdk/foo/client.py b/src/sap_cloud_sdk/foo/client.py +new file mode 100644 +--- /dev/null ++++ b/src/sap_cloud_sdk/foo/client.py +@@ -0,0 +1,1 @@ ++class FooClient: pass +EOF + result=$(REPO_ROOT="$tmpd" DIFF_FILE="$tmpd/diff" LANGUAGE=python bash "$SCRIPT_DIR/check-patterns.sh" 2>/dev/null) + # No factory-related PT-01 finding + factory_flag=$(echo "$result" | jq '[.findings[] | select(.rule=="PY-PT-01" and (.message | contains("factory")))] | length') + [ "$factory_flag" = "0" ] + rm -rf "$tmpd" +} + +@test "FP-G-01: PT-01 emits zero findings when module has every universal element" { + [ -f "$SCRIPT_DIR/check-patterns.sh" ] || skip "check-patterns.sh not in this batch" + tmpd=$(mktemp -d) + # 3 peers with user-guide.md; foo also has it and everything else uncommon. + mkdir -p "$tmpd/src/sap_cloud_sdk/"{a,b,c,foo} + for m in a b c foo; do + touch "$tmpd/src/sap_cloud_sdk/$m/user-guide.md" + echo "class ${m^}Client: pass" > "$tmpd/src/sap_cloud_sdk/$m/client.py" + done + cat > "$tmpd/diff" <<'EOF' +diff --git a/src/sap_cloud_sdk/foo/client.py b/src/sap_cloud_sdk/foo/client.py +new file mode 100644 +--- /dev/null ++++ b/src/sap_cloud_sdk/foo/client.py +@@ -0,0 +1,1 @@ ++class FooClient: pass +EOF + result=$(REPO_ROOT="$tmpd" DIFF_FILE="$tmpd/diff" LANGUAGE=python bash "$SCRIPT_DIR/check-patterns.sh" 2>/dev/null) + pt01_count=$(echo "$result" | jq '[.findings[] | select(.rule=="PY-PT-01")] | length') + [ "$pt01_count" = "0" ] + rm -rf "$tmpd" +}