diff --git a/.claude/commands/review-new-module.md b/.claude/commands/review-new-module.md new file mode 100644 index 00000000..85dde012 --- /dev/null +++ b/.claude/commands/review-new-module.md @@ -0,0 +1,19 @@ +# /review-new-module — orchestrator slash command +Run the SDK Module Review skill against a PR. + +## Usage +- `/review-new-module ` — full review, posts findings +- `/review-new-module --dry-run` — analysis only, no posting + +## What it does +1. Detects language (Python vs Java) via `pyproject.toml` / `pom.xml` +2. Fetches PR diff + body +3. Runs 20 deterministic checks in parallel (secrets, license, disclosure, hardcode, telemetry, docs, bdd, patterns, versioning, commits, errors-logging, testing-depth, http-hygiene, concurrency, deps-supply, deletion-hygiene, constants, binding-shape, quality-gate-parity, pr-size) +4. Applies baseline exemptions + scope predicates + tier gating +5. Detects breaking changes via AST diff +6. Posts 4 signals: inline comments, summary comment, check-run, label + +## Implementation +Run: `bash .claude/scripts/orchestrate.sh ` + +The orchestrator is 100% deterministic — no LLM calls in CI. When run inside Claude Code, the LLM can enrich the summary comment before posting (this happens naturally in the session). diff --git a/.claude/config/baseline.json b/.claude/config/baseline.json new file mode 100644 index 00000000..45c15950 --- /dev/null +++ b/.claude/config/baseline.json @@ -0,0 +1,25 @@ +{ + "created_at": "2026-07-15T00:00:00Z", + "at_commit": "TBD", + "schema_version": 1, + "exemptions": { + "PY-LIC-01": { + "reason": "Repo uses REUSE.toml aggregate — pre-existing files exempt", + "detector": "REUSE.toml exists with path='**' aggregate annotation", + "scope": "repo", + "files_exempted_glob": "src/**/*.py" + }, + "PY-LIC-02": { + "reason": "Same as PY-LIC-01", + "scope": "repo" + }, + "JV-LIC-01": { + "reason": "cloud-sdk-java has no REUSE.toml AND no per-file SPDX baseline — rule kept SHADOW pending team decision on Wave-4 baseline PR", + "scope": "repo" + }, + "JV-LIC-02": { + "reason": "Same as JV-LIC-01", + "scope": "repo" + } + } +} diff --git a/.claude/config/module-aliases.yaml b/.claude/config/module-aliases.yaml new file mode 100644 index 00000000..c14ebbbb --- /dev/null +++ b/.claude/config/module-aliases.yaml @@ -0,0 +1,5 @@ +# Module name aliases across languages. +# Used by check-bdd.sh to resolve cross-repo feature file location. +aliases: + - python: dms + java: documentmanagement diff --git a/.claude/config/rules.yaml b/.claude/config/rules.yaml new file mode 100644 index 00000000..1428f0fa --- /dev/null +++ b/.claude/config/rules.yaml @@ -0,0 +1,131 @@ +version: 2 + +# ===================================================== +# rules.yaml — SDK Module Review rule catalog +# All rules default to their tier here; may be overridden per-repo. +# BLOCK_LOCKED rules cannot be softened or suppressed. +# ===================================================== + +baseline: + file: .claude/baseline.json + regenerate_with: orchestrate.sh --baseline + +tiers: + SHADOW: { posts: false, blocks: false } + FLAG: { posts: true, blocks: false } + BLOCK: { posts: true, blocks: true } + BLOCK_LOCKED: { posts: true, blocks: true, suppressible: false, downgradable: false } + +rules: + # -------- BREAKING FAMILY (all locked) -------- + BREAKING-01: { tier: BLOCK_LOCKED } + BREAKING-02: { tier: BLOCK_LOCKED } + BREAKING-03: { tier: FLAG } + BREAKING-04: { tier: FLAG } + + # -------- SECRETS (all locked) -------- + SEC-01: { tier: BLOCK_LOCKED } + SEC-02: { tier: BLOCK_LOCKED } + SEC-03: { tier: BLOCK_LOCKED } + SEC-04: { tier: BLOCK_LOCKED } + SEC-05: { tier: BLOCK_LOCKED } + SEC-06: { tier: BLOCK_LOCKED } + SEC-07: { tier: BLOCK_LOCKED } + SEC-08: { tier: BLOCK_LOCKED } + SEC-10: { tier: BLOCK_LOCKED } + + # -------- LICENSE -------- + LIC-01: { tier: BLOCK, predicates: { not: reuse_toml_aggregate_present, diff_scope: file_in_added_set } } + LIC-02: { tier: BLOCK, predicates: { not: reuse_toml_aggregate_present, diff_scope: file_in_added_set } } + + # -------- DISCLOSURE -------- + DIS-01: { tier: BLOCK, profile: { public: BLOCK, internal: FLAG } } + DIS-02: { tier: BLOCK, profile: { public: BLOCK, internal: PASS } } + DIS-06: { tier: BLOCK_LOCKED } + DIS-07: { tier: BLOCK } + DIS-08: { tier: SHADOW } + + # -------- HARDCODE -------- + HC-01: { tier: BLOCK } + HC-02: { tier: BLOCK } + HC-03: { tier: BLOCK_LOCKED } + HC-04: { tier: FLAG } + HC-06: { tier: FLAG } + + # -------- TELEMETRY -------- + PY-TEL-02: { tier: BLOCK } + JV-TEL-02: { tier: BLOCK } + PY-TEL-06: { tier: BLOCK, predicates: { diff_scope: new_decorator_added } } + PY-TEL-08: { tier: SHADOW } + + # -------- DOCS -------- + DC-01: { tier: BLOCK } + DC-02: { tier: BLOCK } + DC-11: { tier: BLOCK } + DC-12: { tier: BLOCK } + DC-13: { tier: BLOCK } + DC-14: { tier: BLOCK } + + # -------- BDD -------- + BDD-01: { tier: BLOCK } + 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-03: { tier: FLAG } + PY-PT-04: { tier: FLAG } + PY-PT-08: { tier: FLAG } + JV-PT-05: { tier: FLAG } + + # -------- VERSIONING -------- + VER-01: { tier: BLOCK, predicates: { commit_types_or_breaking: [feat, feat!, breaking] } } + VER-07: { tier: FLAG } + PY-VER-08: { tier: SHADOW } + PY-VER-09: { tier: SHADOW } + + # -------- ERRORS/LOGGING -------- + EL-01: { tier: FLAG } + EL-02: { tier: FLAG } + EL-04: { tier: BLOCK } + PY-EL-11: { tier: SHADOW } + + # -------- TESTING -------- + TD-01: { tier: FLAG } + TD-10: { tier: BLOCK } + TD-checkbox: { tier: FLAG } + + # -------- HTTP -------- + HTTP-01: { tier: FLAG, predicates: { diff_scope: line_in_added_set } } + + # -------- CONCURRENCY -------- + CC-01: { tier: FLAG } + JV-CC-05: { tier: SHADOW } + + # -------- DEPS -------- + DEP-03: { tier: FLAG } + DEP-04: { tier: BLOCK_LOCKED } + + # -------- COMMITS -------- + COM-01: { tier: BLOCK } + + # -------- DELETION -------- + DEL-01: { tier: BLOCK } + + # -------- CONSTANTS -------- + PY-CON-01: { tier: FLAG } + + # -------- BINDING -------- + BND-02: { tier: BLOCK_LOCKED } + BND-04: { tier: BLOCK } + BND-05: { tier: BLOCK_LOCKED } + + # -------- QUALITY GATE -------- + QG-01: { tier: FLAG } + QG-02: { tier: FLAG } + + # -------- PR SIZE (advisory, SHADOW during rollout) -------- + PR-SIZE-01: { tier: SHADOW } + PR-SIZE-02: { tier: SHADOW } + PR-SIZE-03: { tier: SHADOW } + PR-SIZE-05: { tier: SHADOW } diff --git a/.claude/scripts/aggregate.sh b/.claude/scripts/aggregate.sh new file mode 100755 index 00000000..97d857bf --- /dev/null +++ b/.claude/scripts/aggregate.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# aggregate.sh — merge N check reports into a single summary, applying rule tiers. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TMPDIR_RUN="$1" +RULES_YAML="${RULES_YAML:-$SCRIPT_DIR/../config/rules.yaml}" + +# get_tier — reads rules.yaml, returns tier or empty +get_tier() { + local rule="$1" + # rules.yaml format: " RULE-ID: { tier: X, ... }" + awk -v rule="$rule" ' + match($0, "^ " rule ":") { + if (match($0, /tier:[[:space:]]*[A-Z_]+/)) { + t = substr($0, RSTART, RLENGTH) + sub(/^tier:[[:space:]]*/, "", t) + print t + exit + } + } + ' "$RULES_YAML" 2>/dev/null +} + +# Collect all report-*.json files +reports=$(ls "$TMPDIR_RUN"/report-*.json 2>/dev/null | sort) +if [ -z "$reports" ]; then + echo '{"findings":[],"shadow_findings":[],"summary":{"block_count":0,"flag_count":0,"shadow_count":0,"locked_count":0},"per_check_summary":{}}' + exit 0 +fi + +# Merge raw findings, then re-classify each finding by tier from rules.yaml +merged=$(mktemp) +# shellcheck disable=SC2086 +jq -s '.' $reports > "$merged" + +# For each finding, look up its rule tier and adjust +retagged_findings="[]" +retagged_shadow="[]" +locked_count=0 + +n=$(jq '[.[] | .findings // [] | length] | add // 0' "$merged") +if [ "$n" -gt 0 ]; then + all_findings=$(jq '[.[] | .findings // []] | flatten' "$merged") + rule_ids=$(echo "$all_findings" | jq -r '.[] | .rule' | sort -u) + + # Build lookup: rule -> tier + tier_map='{}' + while IFS= read -r rule; do + [ -z "$rule" ] && continue + tier=$(get_tier "$rule") + [ -z "$tier" ] && tier="FLAG" # default + tier_map=$(echo "$tier_map" | jq --arg r "$rule" --arg t "$tier" '. + {($r): $t}') + done <<< "$rule_ids" + + # Split findings into posted vs shadow based on tier + retagged_findings=$(echo "$all_findings" | jq --argjson tiers "$tier_map" ' + map( + . as $f + | ($tiers[$f.rule] // "FLAG") as $tier + | if $tier == "SHADOW" then empty + elif $tier == "BLOCK_LOCKED" then . + {severity: "BLOCK", tier: "BLOCK_LOCKED", locked: true} + elif $tier == "BLOCK" then . + {severity: "BLOCK", tier: "BLOCK"} + elif $tier == "FLAG" then . + {severity: "FLAG", tier: "FLAG"} + else . + {tier: $tier} end + ) + ') + retagged_shadow=$(echo "$all_findings" | jq --argjson tiers "$tier_map" ' + map( + . as $f + | ($tiers[$f.rule] // "FLAG") as $tier + | if $tier == "SHADOW" then . + {tier: "SHADOW"} else empty end + ) + ') + locked_count=$(echo "$retagged_findings" | jq '[.[] | select(.locked == true)] | length') +fi + +block_count=$(echo "$retagged_findings" | jq '[.[] | select(.severity == "BLOCK")] | length') +flag_count=$(echo "$retagged_findings" | jq '[.[] | select(.severity == "FLAG")] | length') +shadow_count=$(echo "$retagged_shadow" | jq 'length') +per_check=$(jq '[.[] | {(.check): {status: .status, count: ((.findings // []) | length)}}] | add' "$merged") + +jq -n \ + --argjson findings "$retagged_findings" \ + --argjson shadow "$retagged_shadow" \ + --argjson block "$block_count" \ + --argjson flag "$flag_count" \ + --argjson shadow_c "$shadow_count" \ + --argjson locked "$locked_count" \ + --argjson per_check "$per_check" \ + '{ + version: "1.0.0", + findings: $findings, + shadow_findings: $shadow, + summary: { + block_count: $block, + flag_count: $flag, + shadow_count: $shadow_c, + locked_count: $locked + }, + per_check_summary: $per_check + }' + +rm -f "$merged" diff --git a/.claude/scripts/check-bdd.sh b/.claude/scripts/check-bdd.sh new file mode 100755 index 00000000..5f09a735 --- /dev/null +++ b/.claude/scripts/check-bdd.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# check-bdd.sh — feature file existence + cross-language parity via alias map. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/json-emit.sh" + +LANGUAGE="${LANGUAGE:-python}" +REPO_ROOT="${REPO_ROOT:-.}" +DIFF_FILE="${DIFF_FILE:-/dev/stdin}" +SDK_SIBLING_PATH="${SDK_SIBLING_PATH:-}" +CONFIG_DIR="${CONFIG_DIR:-$REPO_ROOT/.claude/config}" + +STARTED=$(now_iso) +findings=$(mktemp); trap 'rm -f "$findings"' EXIT + +diff_content=$(cat "$DIFF_FILE" 2>/dev/null || echo "") + +# Extract new/modified modules from diff +if [ "$LANGUAGE" = "python" ]; then + modules=$(echo "$diff_content" | grep -oE 'src/sap_cloud_sdk/[a-z_]+/' 2>/dev/null | sed 's|src/sap_cloud_sdk/||; s|/$||' | grep -v '^core$' | sort -u || true) +else + modules=$(echo "$diff_content" | grep -oE 'src/main/java/com/sap/cloud/sdk/[a-z_]+/' 2>/dev/null | sed 's|src/main/java/com/sap/cloud/sdk/||; s|/$||' | grep -v '^core$' | sort -u || true) +fi + +# resolve alias mapping (python name → java name and vice versa) +resolve_sibling_name() { + local mod="$1" dir="$LANGUAGE" + local aliases="$CONFIG_DIR/module-aliases.yaml" + if [ ! -f "$aliases" ]; then echo "$mod"; return; fi + # Simple parser: "- python: X\n java: Y" pairs + if [ "$dir" = "python" ]; then + # look for "python: $mod" then get "java: " + awk -v mod="$mod" ' + /python:/ { p_name=$2 } + /java:/ { j_name=$2; if (p_name == mod) { print j_name; exit } } + ' "$aliases" | head -1 + return + else + awk -v mod="$mod" ' + /python:/ { p_name=$2 } + /java:/ { j_name=$2; if (j_name == mod) { print p_name; exit } } + ' "$aliases" | head -1 + return + fi +} + +while IFS= read -r mod; do + [ -z "$mod" ] && continue + + # Path for THIS repo's feature file + if [ "$LANGUAGE" = "python" ]; then + feature_path="$REPO_ROOT/tests/$mod/integration/$mod.feature" + mod_dir="$REPO_ROOT/src/sap_cloud_sdk/$mod" + else + feature_path="$REPO_ROOT/src/test/resources/com/sap/applicationfoundation/$mod/integration/$mod.feature" + 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 + # check if it's a "new" module (created in this PR) + if echo "$diff_content" | grep -qE "new file mode.*($mod/)"; 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" + fi + fi + + # BDD-02: sibling repo parity + sibling_name=$(resolve_sibling_name "$mod") + [ -z "$sibling_name" ] && sibling_name="$mod" + + if [ -n "$SDK_SIBLING_PATH" ] && [ -d "$SDK_SIBLING_PATH" ]; then + if [ "$LANGUAGE" = "python" ]; then + # Python repo — sibling is Java + sibling_feature="$SDK_SIBLING_PATH/src/test/resources/com/sap/applicationfoundation/$sibling_name/integration/$sibling_name.feature" + sibling_module_dir="$SDK_SIBLING_PATH/src/main/java/com/sap/cloud/sdk/$sibling_name" + else + sibling_feature="$SDK_SIBLING_PATH/tests/$sibling_name/integration/$sibling_name.feature" + sibling_module_dir="$SDK_SIBLING_PATH/src/sap_cloud_sdk/$sibling_name" + fi + # If the sibling module dir exists, and sibling has no feature but we do (or vice versa) + if [ -d "$sibling_module_dir" ] && [ ! -f "$sibling_feature" ] && [ -f "$feature_path" ]; then + emit_finding "BDD-02" "FLAG" "$feature_path" 1 \ + "Sibling SDK ($sibling_name) has module but no BDD feature — parity broken" "" >> "$findings" + fi + if [ -d "$sibling_module_dir" ] && [ -f "$sibling_feature" ] && [ ! -f "$feature_path" ] && [ -d "$mod_dir" ]; then + emit_finding "BDD-02" "BLOCK" "tests/.../$mod.feature" 1 \ + "Module '$mod' exists in sibling SDK ($sibling_name) with BDD feature — this repo must have equivalent" "" >> "$findings" + fi + fi +done <<< "$modules" + +status=$(status_from_findings < "$findings") +emit_report "bdd" "$LANGUAGE" "$status" "$STARTED" < "$findings" diff --git a/.claude/scripts/check-binding-shape.sh b/.claude/scripts/check-binding-shape.sh new file mode 100755 index 00000000..3559cd9b --- /dev/null +++ b/.claude/scripts/check-binding-shape.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# check-binding-shape.sh — placeholder: stub check emitting empty findings. +# TODO: implement rules from 01-PYTHON.md / 02-JAVA.md §3.binding-shape +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/json-emit.sh" +source "$SCRIPT_DIR/lib/skill-self-skip.sh" + +LANGUAGE="${LANGUAGE:-python}" +DIFF_FILE="${DIFF_FILE:-/dev/stdin}" +REPO_ROOT="${REPO_ROOT:-.}" + +STARTED=$(now_iso) +findings=$(mktemp); trap 'rm -f "$findings"' EXIT +diff_content=$(cat "$DIFF_FILE" 2>/dev/null || echo "") + +# BND-02 (LOCKED): token URL via string concat + "/oauth/token" +echo "$diff_content" | awk ' + BEGIN { file=""; line=0 } + /^diff --git a\// { file=$4; sub(/^b\//, "", file); line=0; next } + /^@@/ { if (match($0, /\+[0-9]+/)) line=substr($0, RSTART+1, RLENGTH-1)+0; next } + /^\+/ && !/^\+\+\+/ { print file "\t" line "\t" substr($0, 2); line++; next } + /^ / { line++; next } +' | while IFS=$'\t' read -r file line_num content; do + [ -z "$file" ] && continue + # Self-review protection: skip skill files + if [ "$(is_skill_file "$file")" = "true" ]; then continue; fi + if echo "$content" | grep -qE 'rstrip\("/"\)[[:space:]]*\+[[:space:]]*"/oauth/token"|\.replaceAll\("/\+\$", ""\)[[:space:]]*\+[[:space:]]*"/oauth/token"'; then + emit_finding "BND-02" "BLOCK" "$file" "$line_num" \ + "BTP token URL built via string concat — different services expose different fields" \ + "Use HttpUrl.parse().newBuilder() or honour a 'token_url' field if present" >> "$findings" + fi + if echo "$content" | grep -qE '\+[[:space:]]*"/oauth/token"'; then + emit_finding "BND-02" "BLOCK" "$file" "$line_num" \ + "Hardcoded /oauth/token path — BTP services vary" "" >> "$findings" + fi +done + +status=$(status_from_findings < "$findings") +emit_report "binding-shape" "$LANGUAGE" "$status" "$STARTED" < "$findings" diff --git a/.claude/scripts/check-commits.sh b/.claude/scripts/check-commits.sh new file mode 100755 index 00000000..6c4859a5 --- /dev/null +++ b/.claude/scripts/check-commits.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# check-commits.sh — Conventional Commits enforcement. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/json-emit.sh" + +LANGUAGE="${LANGUAGE:-python}" +BASE_SHA="${BASE_SHA:-HEAD~10}" +HEAD_SHA="${HEAD_SHA:-HEAD}" + +STARTED=$(now_iso) +findings=$(mktemp); trap 'rm -f "$findings"' EXIT + +# List commit subjects +commits=$(git log "${BASE_SHA}..${HEAD_SHA}" --format='%H %s' 2>/dev/null || echo "") +prefix_regex='^(feat|fix|refactor|chore|docs|test|ci|build|perf|style|revert)(\([a-z0-9_/-]+\))?!?:[[:space:]]+.+' + +echo "$commits" | while IFS= read -r line; do + [ -z "$line" ] && continue + sha=$(echo "$line" | cut -d' ' -f1) + subject=$(echo "$line" | cut -d' ' -f2-) + # Skip merge commits (identified by 2+ parents, robust regardless of message shape) + parents=$(git rev-list --parents -n 1 "$sha" 2>/dev/null | awk '{print NF-1}') + if [ "${parents:-0}" -gt 1 ]; then continue; fi + # Also skip legacy "Merge branch/pull/etc" defaults + if echo "$subject" | grep -qiE '^Merge '; then continue; fi + if ! echo "$subject" | grep -qE "$prefix_regex"; then + emit_finding "COM-01" "BLOCK" "COMMIT:$sha" 1 \ + "Commit '$subject' does not follow Conventional Commits" \ + "Prefix with feat:/fix:/chore:/docs:/test:/refactor:/ci:/build:/perf:/style:/revert:" >> "$findings" + fi +done + +status=$(status_from_findings < "$findings") +emit_report "commits" "$LANGUAGE" "$status" "$STARTED" < "$findings" diff --git a/.claude/scripts/check-concurrency.sh b/.claude/scripts/check-concurrency.sh new file mode 100755 index 00000000..6405efdf --- /dev/null +++ b/.claude/scripts/check-concurrency.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# check-concurrency.sh — placeholder: stub check emitting empty findings. +# TODO: implement rules from 01-PYTHON.md / 02-JAVA.md §3.concurrency +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/json-emit.sh" +source "$SCRIPT_DIR/lib/skill-self-skip.sh" + +LANGUAGE="${LANGUAGE:-python}" +DIFF_FILE="${DIFF_FILE:-/dev/stdin}" +REPO_ROOT="${REPO_ROOT:-.}" + +STARTED=$(now_iso) +findings=$(mktemp); trap 'rm -f "$findings"' EXIT +diff_content=$(cat "$DIFF_FILE" 2>/dev/null || echo "") + +# CC-01: asyncio.Queue with no accompanying Lock/set — flag when queue+append pattern present +echo "$diff_content" | awk ' + BEGIN { file=""; line=0 } + /^diff --git a\// { file=$4; sub(/^b\//, "", file); line=0; next } + /^@@/ { if (match($0, /\+[0-9]+/)) line=substr($0, RSTART+1, RLENGTH-1)+0; next } + /^\+/ && !/^\+\+\+/ { print file "\t" line "\t" substr($0, 2); line++; next } + /^ / { line++; next } +' | while IFS=$'\t' read -r file line_num content; do + [ -z "$file" ] && continue + if [ "$(is_skill_file "$file")" = "true" ]; then continue; fi + 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" \ + "asyncio.Queue without dedup — if items must be unique, add a set() + asyncio.Lock" "" >> "$findings" + fi + fi +done + +status=$(status_from_findings < "$findings") +emit_report "concurrency" "$LANGUAGE" "$status" "$STARTED" < "$findings" diff --git a/.claude/scripts/check-constants.sh b/.claude/scripts/check-constants.sh new file mode 100755 index 00000000..2a72a3b7 --- /dev/null +++ b/.claude/scripts/check-constants.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# check-constants.sh — placeholder: stub check emitting empty findings. +# TODO: implement rules from 01-PYTHON.md / 02-JAVA.md §3.constants +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/json-emit.sh" + +LANGUAGE="${LANGUAGE:-python}" +DIFF_FILE="${DIFF_FILE:-/dev/stdin}" +REPO_ROOT="${REPO_ROOT:-.}" + +STARTED=$(now_iso) +findings=$(mktemp); trap 'rm -f "$findings"' EXIT +diff_content=$(cat "$DIFF_FILE" 2>/dev/null || echo "") + +# CON-01 via AST on Python files +if [ "$LANGUAGE" = "python" ]; then + changed=$(echo "$diff_content" | grep -oE '^\+\+\+ b/src/.*\.py' | sed 's|^+++ b/||' | sort -u) + if [ -n "$changed" ]; then + # shellcheck disable=SC2086 + python3 "$SCRIPT_DIR/lib/ast_python_checks.py" con-01 $changed 2>/dev/null >> "$findings" || true + fi +fi + +status=$(status_from_findings < "$findings") +emit_report "constants" "$LANGUAGE" "$status" "$STARTED" < "$findings" diff --git a/.claude/scripts/check-deletion-hygiene.sh b/.claude/scripts/check-deletion-hygiene.sh new file mode 100755 index 00000000..7fa90e01 --- /dev/null +++ b/.claude/scripts/check-deletion-hygiene.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# check-deletion-hygiene.sh — placeholder: stub check emitting empty findings. +# TODO: implement rules from 01-PYTHON.md / 02-JAVA.md §3.deletion-hygiene +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/json-emit.sh" + +LANGUAGE="${LANGUAGE:-python}" +DIFF_FILE="${DIFF_FILE:-/dev/stdin}" +REPO_ROOT="${REPO_ROOT:-.}" + +STARTED=$(now_iso) +findings=$(mktemp); trap 'rm -f "$findings"' EXIT +diff_content=$(cat "$DIFF_FILE" 2>/dev/null || echo "") + +# DEL-01: symbol removed from __all__ but references remain in codebase +if [ "$LANGUAGE" = "python" ]; then + # Extract removed __all__ entries + removed_symbols=$(echo "$diff_content" | awk ' + /\+\+\+ b\/.*__init__\.py$/ { in_init=1; next } + /^diff --git/ { in_init=0 } + in_init && /^-[[:space:]]*"[A-Za-z_][A-Za-z0-9_]*"/ { + match($0, /"[^"]+"/); print substr($0, RSTART+1, RLENGTH-2) + } + ') + while IFS= read -r sym; do + [ -z "$sym" ] && continue + # search codebase (excluding the file where it was removed) + hits=$(grep -rEIln "\b${sym}\b" "$REPO_ROOT/src" 2>/dev/null | wc -l | tr -d ' ') + if [ "$hits" -gt 0 ]; then + emit_finding "DEL-01" "BLOCK" "src/" 1 \ + "Symbol '$sym' removed from __all__ but still referenced in $hits files" "" >> "$findings" + fi + done <<< "$removed_symbols" +fi + +status=$(status_from_findings < "$findings") +emit_report "deletion-hygiene" "$LANGUAGE" "$status" "$STARTED" < "$findings" diff --git a/.claude/scripts/check-deps-supply.sh b/.claude/scripts/check-deps-supply.sh new file mode 100755 index 00000000..54fea34e --- /dev/null +++ b/.claude/scripts/check-deps-supply.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# check-deps-supply.sh — placeholder: stub check emitting empty findings. +# TODO: implement rules from 01-PYTHON.md / 02-JAVA.md §3.deps-supply +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/json-emit.sh" + +LANGUAGE="${LANGUAGE:-python}" +DIFF_FILE="${DIFF_FILE:-/dev/stdin}" +REPO_ROOT="${REPO_ROOT:-.}" + +STARTED=$(now_iso) +findings=$(mktemp); trap 'rm -f "$findings"' EXIT +diff_content=$(cat "$DIFF_FILE" 2>/dev/null || echo "") + +# DEP-04: internal artifactory index URL +if echo "$diff_content" | grep -qE '\+.*(index-url|extra-index-url).*int\.repositories\.cloud\.sap'; then + emit_finding "DEP-04" "BLOCK" "config" 1 \ + "Internal artifactory --index-url reference in public artifact" "" >> "$findings" +fi +# DEP-03: pyproject.toml deps changed but uv.lock not +if [ "$LANGUAGE" = "python" ]; then + pyproject_deps_changed=$(echo "$diff_content" | grep -cE '^\+.*"[a-z][a-z0-9_-]*[><=~!]+' || echo 0) + uv_lock_changed=$(echo "$diff_content" | grep -c '^\+\+\+ b/uv\.lock' || echo 0) + if [ "$pyproject_deps_changed" -gt 0 ] && [ "$uv_lock_changed" -eq 0 ]; then + # only fire if pyproject.toml dep table (not [project] version) changed + if echo "$diff_content" | grep -qE '^\+.*\[project\.(dependencies|optional-dependencies)\]|^\+[[:space:]]+"[a-z][a-z0-9_-]*[><=~!].*"'; then + emit_finding "DEP-03" "FLAG" "pyproject.toml" 1 \ + "pyproject.toml dependencies changed but uv.lock not updated" "" >> "$findings" + fi + fi +fi + +status=$(status_from_findings < "$findings") +emit_report "deps-supply" "$LANGUAGE" "$status" "$STARTED" < "$findings" diff --git a/.claude/scripts/check-disclosure.sh b/.claude/scripts/check-disclosure.sh new file mode 100755 index 00000000..b320a685 --- /dev/null +++ b/.claude/scripts/check-disclosure.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# check-disclosure.sh — open-source disclosure hygiene. +# Detects SAP-internal URLs, ORD IDs, internal Jira, unfilled PR body templates. +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/skill-self-skip.sh +source "$SCRIPT_DIR/lib/skill-self-skip.sh" + +LANGUAGE="${LANGUAGE:-python}" +PROFILE="${DISCLOSURE_PROFILE:-public}" # "public" or "internal" +DIFF_FILE="${DIFF_FILE:-/dev/stdin}" +PR_BODY_FILE="${PR_BODY_FILE:-}" + +STARTED=$(now_iso) +findings=$(mktemp) +trap 'rm -f "$findings"' EXIT + +diff_content=$(cat "$DIFF_FILE" 2>/dev/null || echo "") + +# Determine severity based on profile +sev_public() { if [ "$PROFILE" = "public" ]; then echo "BLOCK"; else echo "FLAG"; fi; } +sev_internal_ok() { if [ "$PROFILE" = "public" ]; then echo "BLOCK"; else echo "PASS"; fi; } + +# Scan added lines only +echo "$diff_content" | awk ' + BEGIN { file=""; line=0 } + /^diff --git a\// { file=$4; sub(/^b\//, "", file); line=0; next } + /^@@/ { if (match($0, /\+[0-9]+/)) line=substr($0, RSTART+1, RLENGTH-1)+0; next } + /^\+/ && !/^\+\+\+/ { print file "\t" line "\t" substr($0, 2); line++; next } + /^ / { line++; next } +' | while IFS=$'\t' read -r file line_num content; do + [ -z "$file" ] && continue + # Self-review protection: skip skill files + if [ "$(is_skill_file "$file")" = "true" ]; then continue; fi + + # DIS-01: SAP-internal hostnames + if echo "$content" | grep -qEi '(int\.repositories\.cloud\.sap|\.tools\.sap|\.wdf\.sap\.corp|\.mo\.sap\.corp|jira\.tools\.sap)'; then + emit_finding "DIS-01" "$(sev_public)" "$file" "$line_num" "SAP-internal hostname detected in code" "" >> "$findings" + fi + # DIS-02: Internal Jira URL + 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" + fi + # DIS-06: Internal artifactory index-url + if echo "$content" | grep -qEi '\-\-index-url\s+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" + fi +done + +# DIS-07/08: PR body +if [ -n "$PR_BODY_FILE" ] && [ -f "$PR_BODY_FILE" ]; then + body=$(cat "$PR_BODY_FILE") + # DIS-07: unfilled Closes # placeholder + if echo "$body" | grep -qE 'Closes #'; then + emit_finding "DIS-07" "BLOCK" "PR_BODY" 1 "PR body contains unfilled 'Closes #' placeholder" "" >> "$findings" + fi + # DIS-08 (SHADOW): internal URLs / Jira in PR body + if echo "$body" | grep -qEi '\.tools\.sap|\.wdf\.sap\.corp|jira\.tools\.sap'; then + emit_finding "DIS-08" "FLAG" "PR_BODY" 1 "PR body references SAP-internal URLs/Jira — remove from public-visible artifacts" "" >> "$findings" + fi +fi + +status=$(status_from_findings < "$findings") +emit_report "disclosure" "$LANGUAGE" "$status" "$STARTED" < "$findings" diff --git a/.claude/scripts/check-docs.sh b/.claude/scripts/check-docs.sh new file mode 100755 index 00000000..b12e7f95 --- /dev/null +++ b/.claude/scripts/check-docs.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# check-docs.sh — documentation completeness including BTP deps and regional availability. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/json-emit.sh" + +LANGUAGE="${LANGUAGE:-python}" +REPO_ROOT="${REPO_ROOT:-.}" +DIFF_FILE="${DIFF_FILE:-/dev/stdin}" + +STARTED=$(now_iso) +findings=$(mktemp); trap 'rm -f "$findings"' EXIT + +diff_content=$(cat "$DIFF_FILE" 2>/dev/null || echo "") + +# Detect new module directories (module has new files at top-level) +if [ "$LANGUAGE" = "python" ]; then + new_modules=$(echo "$diff_content" | grep -oE '^\+\+\+ b/src/sap_cloud_sdk/[a-z_]+/[^/]+\.py' | sed 's|^+++ b/src/sap_cloud_sdk/||; s|/[^/]*\.py$||' | sort -u) +else + new_modules=$(echo "$diff_content" | grep -oE '^\+\+\+ b/src/main/java/com/sap/cloud/sdk/[a-z_]+/[^/]+\.java' | sed 's|^+++ b/src/main/java/com/sap/cloud/sdk/||; s|/[^/]*\.java$||' | sort -u) +fi + +while IFS= read -r mod; do + [ -z "$mod" ] && continue + [ "$mod" = "core" ] && continue + + if [ "$LANGUAGE" = "python" ]; then + user_guide="$REPO_ROOT/src/sap_cloud_sdk/$mod/user-guide.md" + mod_dir="$REPO_ROOT/src/sap_cloud_sdk/$mod" + else + user_guide="$REPO_ROOT/src/main/java/com/sap/cloud/sdk/$mod/user-guide.md" + mod_dir="$REPO_ROOT/src/main/java/com/sap/cloud/sdk/$mod" + fi + + # DC-01: user-guide.md exists + if [ ! -f "$user_guide" ]; then + # Only fire if module dir exists (module is new-ish) + if [ -d "$mod_dir" ]; then + emit_finding "DC-01" "BLOCK" "src/.../$mod/user-guide.md" 1 \ + "Module '$mod' missing user-guide.md" \ + "Create $user_guide with sections: ## Installation, ## Quick Start, ## Configuration" >> "$findings" + fi + continue + fi + + # DC-02: required sections + guide_content=$(cat "$user_guide" 2>/dev/null || echo "") + if ! echo "$guide_content" | grep -qE '^##[[:space:]]+(Installation|Import)'; then + emit_finding "DC-02" "FLAG" "$user_guide" 1 "user-guide.md missing ## Installation section" "" >> "$findings" + fi + if ! echo "$guide_content" | grep -qE '^##[[:space:]]+Quick Start'; then + emit_finding "DC-02" "BLOCK" "$user_guide" 1 "user-guide.md missing ## Quick Start section" "" >> "$findings" + fi + + # DC-11..DC-14 (BTP deps + regional) + # Detect module imports/usages + if [ "$LANGUAGE" = "python" ]; then + has_dest=$(grep -rq "from sap_cloud_sdk\.destination" "$mod_dir" 2>/dev/null && echo yes || echo no) + has_frag=$(grep -rq "FragmentClient\|Fragment[[:space:]]*[,)]" "$mod_dir" 2>/dev/null && echo yes || echo no) + has_cert=$(grep -rq "CertificateClient\|Certificate[[:space:]]*[,)]" "$mod_dir" 2>/dev/null && echo yes || echo no) + has_region=$(grep -rqE "REGION|region_id|SupportedRegion|available_regions" "$mod_dir" 2>/dev/null && echo yes || echo no) + else + has_dest=$(grep -rq "com\.sap\.cloud\.sdk\.destination" "$mod_dir" 2>/dev/null && echo yes || echo no) + has_frag=$(grep -rq "FragmentClient" "$mod_dir" 2>/dev/null && echo yes || echo no) + has_cert=$(grep -rq "CertificateClient" "$mod_dir" 2>/dev/null && echo yes || echo no) + has_region=$(grep -rqE "Region\b|regionId|SupportedRegion" "$mod_dir" 2>/dev/null && echo yes || echo no) + fi + + # DC-11: destination dep must be documented + if [ "$has_dest" = "yes" ]; then + if ! echo "$guide_content" | grep -qEi 'destination service|## Dependencies|## Prerequisites'; then + emit_finding "DC-11" "BLOCK" "$user_guide" 1 \ + "Module imports destination service — must document in ## Dependencies section" \ + "Add: ## Dependencies\\n- SAP BTP Destination Service instance" >> "$findings" + fi + fi + # DC-12: fragments + if [ "$has_frag" = "yes" ]; then + if ! echo "$guide_content" | grep -qEi 'fragments'; then + emit_finding "DC-12" "BLOCK" "$user_guide" 1 \ + "Module uses Fragments — must document Fragments prerequisite" "" >> "$findings" + fi + fi + # DC-13: certificates + if [ "$has_cert" = "yes" ]; then + if ! echo "$guide_content" | grep -qEi 'certificate'; then + emit_finding "DC-13" "BLOCK" "$user_guide" 1 \ + "Module uses Certificates — must document Certificate prerequisite" "" >> "$findings" + fi + fi + # DC-14: regional + if [ "$has_region" = "yes" ]; then + if ! echo "$guide_content" | grep -qEi 'Regional Availability|## Limitations|available in|supported region'; then + emit_finding "DC-14" "BLOCK" "$user_guide" 1 \ + "Module has region-specific constants — must document ## Regional Availability" "" >> "$findings" + fi + fi + +done <<< "$new_modules" + +status=$(status_from_findings < "$findings") +emit_report "docs" "$LANGUAGE" "$status" "$STARTED" < "$findings" diff --git a/.claude/scripts/check-errors-logging.sh b/.claude/scripts/check-errors-logging.sh new file mode 100755 index 00000000..2b8334c1 --- /dev/null +++ b/.claude/scripts/check-errors-logging.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# check-errors-logging.sh — exception chaining, log level, sensitive-info in messages. +# Uses AST for chaining/swallow checks. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/json-emit.sh" +source "$SCRIPT_DIR/lib/skill-self-skip.sh" + +LANGUAGE="${LANGUAGE:-python}" +DIFF_FILE="${DIFF_FILE:-/dev/stdin}" +REPO_ROOT="${REPO_ROOT:-.}" + +STARTED=$(now_iso) +findings=$(mktemp); trap 'rm -f "$findings"' EXIT + +diff_content=$(cat "$DIFF_FILE" 2>/dev/null || echo "") + +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 + # shellcheck disable=SC2086 + python3 "$SCRIPT_DIR/lib/ast_python_checks.py" el-01 $changed 2>/dev/null >> "$findings" || true + # shellcheck disable=SC2086 + python3 "$SCRIPT_DIR/lib/ast_python_checks.py" el-02 $changed 2>/dev/null >> "$findings" || true + fi + + # EL-04: secret-like variable name in raise args (grep-based, added lines only) + echo "$diff_content" | awk ' + BEGIN { file=""; line=0 } + /^diff --git a\// { file=$4; sub(/^b\//, "", file); line=0; next } + /^@@/ { if (match($0, /\+[0-9]+/)) line=substr($0, RSTART+1, RLENGTH-1)+0; next } + /^\+/ && !/^\+\+\+/ { print file "\t" line "\t" substr($0, 2); line++; next } + /^ / { line++; next } + ' | while IFS=$'\t' read -r file line_num content; do + [ -z "$file" ] && continue + 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" \ + "Exception message includes secret-like variable — leaks sensitive data" "" >> "$findings" + fi + done +fi + +status=$(status_from_findings < "$findings") +emit_report "errors-logging" "$LANGUAGE" "$status" "$STARTED" < "$findings" diff --git a/.claude/scripts/check-hardcode.sh b/.claude/scripts/check-hardcode.sh new file mode 100755 index 00000000..fde98f7d --- /dev/null +++ b/.claude/scripts/check-hardcode.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# check-hardcode.sh — no hardcoded URLs, credentials, or magic values in impl code. +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/skill-self-skip.sh +source "$SCRIPT_DIR/lib/skill-self-skip.sh" + +LANGUAGE="${LANGUAGE:-python}" +DIFF_FILE="${DIFF_FILE:-/dev/stdin}" + +STARTED=$(now_iso) +findings=$(mktemp) +trap 'rm -f "$findings"' EXIT + +diff_content=$(cat "$DIFF_FILE" 2>/dev/null || echo "") + +# Determine ignore patterns per language +if [ "$LANGUAGE" = "python" ]; then + ignore_files='^(tests?/|mocks?/|docs?/|.*/spec/|.*/constants\.py|.*/user-guide\.md|README\.md)' +else + ignore_files='^(src/test/|mocks?/|docs?/|.*Constants\.java|.*/constants/|.*/user-guide\.md|README\.md)' +fi + +echo "$diff_content" | awk ' + BEGIN { file=""; line=0 } + /^diff --git a\// { file=$4; sub(/^b\//, "", file); line=0; next } + /^@@/ { if (match($0, /\+[0-9]+/)) line=substr($0, RSTART+1, RLENGTH-1)+0; next } + /^\+/ && !/^\+\+\+/ { print file "\t" line "\t" substr($0, 2); line++; next } + /^ / { line++; next } +' | while IFS=$'\t' read -r file line_num content; do + [ -z "$file" ] && continue + if [ "$(is_skill_file "$file")" = "true" ]; then continue; fi + # Filter out test/mock/docs/constants files + if echo "$file" | grep -qE "$ignore_files"; then continue; fi + + # HC-01: hardcoded URL + if echo "$content" | grep -qE 'https?://[a-zA-Z0-9]'; then + # exempt localhost / example.com / example.org (test-safe) + if ! echo "$content" | grep -qE 'https?://(localhost|127\.0\.0\.1|example\.(com|org|net)|reserved\.)'; then + emit_finding "HC-01" "BLOCK" "$file" "$line_num" "Hardcoded URL in implementation — externalize to config" "" >> "$findings" + fi + fi + # 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" + 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" + fi + else + if echo "$content" | grep -qE 'System\.getenv\('; then + emit_finding "HC-04" "FLAG" "$file" "$line_num" "Direct System.getenv() — prefer SecretResolver" "" >> "$findings" + fi + fi + # HC-06: hardcoded timeout numeric literal + if echo "$content" | grep -qiE '(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" + fi +done + +status=$(status_from_findings < "$findings") +emit_report "hardcode" "$LANGUAGE" "$status" "$STARTED" < "$findings" diff --git a/.claude/scripts/check-http-hygiene.sh b/.claude/scripts/check-http-hygiene.sh new file mode 100755 index 00000000..d565cb30 --- /dev/null +++ b/.claude/scripts/check-http-hygiene.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# check-http-hygiene.sh — placeholder: stub check emitting empty findings. +# TODO: implement rules from 01-PYTHON.md / 02-JAVA.md §3.http-hygiene +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/json-emit.sh" +source "$SCRIPT_DIR/lib/skill-self-skip.sh" + +LANGUAGE="${LANGUAGE:-python}" +DIFF_FILE="${DIFF_FILE:-/dev/stdin}" +REPO_ROOT="${REPO_ROOT:-.}" + +STARTED=$(now_iso) +findings=$(mktemp); trap 'rm -f "$findings"' EXIT +diff_content=$(cat "$DIFF_FILE" 2>/dev/null || echo "") + +# HTTP-PY-01: Session per invocation (only fire on newly added lines) +echo "$diff_content" | awk ' + BEGIN { file=""; line=0 } + /^diff --git a\// { file=$4; sub(/^b\//, "", file); line=0; next } + /^@@/ { if (match($0, /\+[0-9]+/)) line=substr($0, RSTART+1, RLENGTH-1)+0; next } + /^\+/ && !/^\+\+\+/ { print file "\t" line "\t" substr($0, 2); line++; next } + /^ / { line++; next } +' | 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 + if echo "$content" | grep -qE '(requests|httpx)\.(Session|AsyncClient)\(\)'; then + emit_finding "HTTP-01" "FLAG" "$file" "$line_num" \ + "HTTP session created per invocation — prefer single instance in __init__" "" >> "$findings" + fi +done + +status=$(status_from_findings < "$findings") +emit_report "http-hygiene" "$LANGUAGE" "$status" "$STARTED" < "$findings" diff --git a/.claude/scripts/check-license-spdx.sh b/.claude/scripts/check-license-spdx.sh new file mode 100755 index 00000000..fabb269d --- /dev/null +++ b/.claude/scripts/check-license-spdx.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# check-license-spdx.sh — verify new source files have SPDX headers. +# REUSE.toml aggregate → rule PASSES (baseline exemption). +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/predicates.sh +source "$SCRIPT_DIR/lib/predicates.sh" + +LANGUAGE="${LANGUAGE:-python}" +REPO_ROOT="${REPO_ROOT:-.}" +DIFF_FILE="${DIFF_FILE:-/dev/stdin}" + +STARTED=$(now_iso) +findings=$(mktemp) +trap 'rm -f "$findings"' EXIT + +# Predicate: REUSE.toml aggregate present? +reuse_present=$(reuse_toml_aggregate_present "$REPO_ROOT") + +diff_content=$(cat "$DIFF_FILE" 2>/dev/null || echo "") + +if [ "$reuse_present" = "true" ]; then + # Baseline exemption: rule OFF for this repo + jq -n --arg check "license-spdx" --arg lang "$LANGUAGE" --arg started "$STARTED" \ + '{ + check: $check, version: "1.0.0", language: $lang, status: "PASS", + started_at: $started, duration_ms: 0, modules_analysed: [], + findings: [], shadow_findings: [], + summary: {block_count: 0, flag_count: 0, suppressed_by_baseline: 0, + note: "REUSE.toml aggregate present — LIC-01/02 exempted"} + }' + exit 0 +fi + +# Find newly added files (starts with "diff --git a/... b/..." followed by "new file mode") +# We match on "+++ b/" lines that appear right after "new file mode" +added_files=$(echo "$diff_content" | awk ' + /^diff --git/ { in_block=1; is_new=0; path=""; next } + in_block && /^new file mode/ { is_new=1; next } + in_block && /^\+\+\+ b\// { if (is_new) print substr($0, 7); in_block=0 } +') + +# REUSE-IgnoreStart +if [ "$LANGUAGE" = "python" ]; then + ext_match='\.py$' + spdx_line='# SPDX-License-Identifier: Apache-2.0' + cprt_line='# SPDX-FileCopyrightText:' +else + ext_match='\.java$' + spdx_line='// SPDX-License-Identifier: Apache-2.0' + cprt_line='// SPDX-FileCopyrightText:' +fi +# REUSE-IgnoreEnd + +while IFS= read -r f; do + [ -z "$f" ] && continue + if ! echo "$f" | grep -qE "$ext_match"; then continue; fi + # Read first 10 lines from the diff for that file to check headers + header=$(echo "$diff_content" | awk -v file="$f" ' + $0 == "+++ b/" file { flag=1; count=0; next } + flag && /^\+/ && !/^\+\+\+/ { print substr($0, 2); count++; if (count>=10) exit } + flag && /^diff --git/ { exit } + ') + if ! echo "$header" | grep -qF "$spdx_line"; then + emit_finding "LIC-01" "BLOCK" "$f" 1 "New source file missing SPDX-License-Identifier header" "" >> "$findings" + fi + if ! echo "$header" | grep -qF "$cprt_line"; then + emit_finding "LIC-02" "BLOCK" "$f" 1 "New source file missing SPDX-FileCopyrightText header" "" >> "$findings" + fi +done <<< "$added_files" + +status=$(status_from_findings < "$findings") +emit_report "license-spdx" "$LANGUAGE" "$status" "$STARTED" < "$findings" diff --git a/.claude/scripts/check-patterns.sh b/.claude/scripts/check-patterns.sh new file mode 100755 index 00000000..c3cc7b83 --- /dev/null +++ b/.claude/scripts/check-patterns.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# check-patterns.sh — idiomatic patterns (factory, exceptions, py.typed). +# Uses module_shape predicate to skip non-client modules. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/json-emit.sh" +source "$SCRIPT_DIR/lib/predicates.sh" + +LANGUAGE="${LANGUAGE:-python}" +REPO_ROOT="${REPO_ROOT:-.}" +DIFF_FILE="${DIFF_FILE:-/dev/stdin}" + +STARTED=$(now_iso) +findings=$(mktemp); trap 'rm -f "$findings"' EXIT + +diff_content=$(cat "$DIFF_FILE" 2>/dev/null || echo "") + +# Detect module dirs touched +if [ "$LANGUAGE" = "python" ]; then + modules=$(echo "$diff_content" | grep -oE 'src/sap_cloud_sdk/[a-z_]+/' | sed 's|src/sap_cloud_sdk/||; s|/$||' | grep -v '^core$' | sort -u) +else + modules=$(echo "$diff_content" | grep -oE 'src/main/java/com/sap/cloud/sdk/[a-z_]+/' | sed 's|src/main/java/com/sap/cloud/sdk/||; s|/$||' | grep -v '^core$' | sort -u) +fi + +while IFS= read -r mod; do + [ -z "$mod" ] && continue + + if [ "$LANGUAGE" = "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 + + [ -d "$mod_dir" ] || continue + + # PY-PT-01 / JV-PT-01: factory exists — only fire on client modules + 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" + 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 + fi + + # PY-PT-03: py.typed marker + if [ "$LANGUAGE" = "python" ]; then + if [ ! -f "$mod_dir/py.typed" ] && [ ! -f "$REPO_ROOT/src/sap_cloud_sdk/py.typed" ]; then + emit_finding "PY-PT-03" "FLAG" "src/sap_cloud_sdk/$mod/py.typed" 1 \ + "Module missing py.typed marker (PEP 561)" "" >> "$findings" + fi + fi + + # PY-PT-04 / JV-PT-05: module-specific exceptions + if [ "$LANGUAGE" = "python" ]; then + if [ ! -f "$mod_dir/exceptions.py" ]; 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" + fi + else + if [ ! -d "$mod_dir/exceptions" ]; then + emit_finding "JV-PT-05" "FLAG" "src/main/java/com/sap/cloud/sdk/$mod/exceptions/" 1 \ + "Module lacks exceptions/ package" "" >> "$findings" + fi + fi + +done <<< "$modules" + +# PY-PT-08 via AST on changed Python files +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 + # shellcheck disable=SC2086 + python3 "$SCRIPT_DIR/lib/ast_python_checks.py" pt-08 $changed_py 2>/dev/null >> "$findings" || true + fi +fi + +status=$(status_from_findings < "$findings") +emit_report "patterns" "$LANGUAGE" "$status" "$STARTED" < "$findings" diff --git a/.claude/scripts/check-pr-size.sh b/.claude/scripts/check-pr-size.sh new file mode 100755 index 00000000..4dcdac65 --- /dev/null +++ b/.claude/scripts/check-pr-size.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# check-pr-size.sh — advisory on large PRs (all FLAG tier, initially SHADOW). +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/json-emit.sh" + +LANGUAGE="${LANGUAGE:-python}" +DIFF_FILE="${DIFF_FILE:-/dev/stdin}" +BASE_SHA="${BASE_SHA:-HEAD~10}" +HEAD_SHA="${HEAD_SHA:-HEAD}" + +STARTED=$(now_iso) +findings=$(mktemp); trap 'rm -f "$findings"' EXIT + +diff_content=$(cat "$DIFF_FILE" 2>/dev/null || echo "") + +# PR-SIZE-01: additions > 800 +additions=$(echo "$diff_content" | grep -cE '^\+[^+]' || echo 0) +if [ "$additions" -gt 800 ]; then + emit_finding "PR-SIZE-01" "FLAG" "." 1 \ + "PR has $additions additions (>800) — consider splitting into stacked PRs for easier review" \ + "See docs/CONTRIBUTING.md § Incremental Delivery for stacked-PR workflow" >> "$findings" +fi + +# PR-SIZE-02: > 15 files +files_touched=$(echo "$diff_content" | grep -cE '^diff --git' || echo 0) +if [ "$files_touched" -gt 15 ]; then + emit_finding "PR-SIZE-02" "FLAG" "." 1 \ + "PR touches $files_touched files (>15) — consider splitting by concern" "" >> "$findings" +fi + +# PR-SIZE-03: > 3 modules +if [ "$LANGUAGE" = "python" ]; then + mods_count=$(echo "$diff_content" | grep -oE 'src/sap_cloud_sdk/[a-z_]+/' | sed 's|src/sap_cloud_sdk/||; s|/$||' | sort -u | wc -l | tr -d ' ') +else + mods_count=$(echo "$diff_content" | grep -oE 'src/main/java/com/sap/cloud/sdk/[a-z_]+/' | sed 's|src/main/java/com/sap/cloud/sdk/||; s|/$||' | sort -u | wc -l | tr -d ' ') +fi +if [ "$mods_count" -gt 3 ]; then + emit_finding "PR-SIZE-03" "FLAG" "." 1 \ + "PR modifies $mods_count modules (>3) — consider one PR per module" "" >> "$findings" +fi + +# PR-SIZE-05: > 30 commits +commit_count=$(git log "${BASE_SHA}..${HEAD_SHA}" --oneline 2>/dev/null | grep -v -c '^Merge' || echo 0) +if [ "$commit_count" -gt 30 ]; then + emit_finding "PR-SIZE-05" "FLAG" "." 1 \ + "PR has $commit_count commits (>30) — consider squashing or splitting" "" >> "$findings" +fi + +status=$(status_from_findings < "$findings") +emit_report "pr-size" "$LANGUAGE" "$status" "$STARTED" < "$findings" diff --git a/.claude/scripts/check-quality-gate-parity.sh b/.claude/scripts/check-quality-gate-parity.sh new file mode 100755 index 00000000..05012231 --- /dev/null +++ b/.claude/scripts/check-quality-gate-parity.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# check-quality-gate-parity.sh — placeholder: stub check emitting empty findings. +# TODO: implement rules from 01-PYTHON.md / 02-JAVA.md §3.quality-gate-parity +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/json-emit.sh" + +LANGUAGE="${LANGUAGE:-python}" +DIFF_FILE="${DIFF_FILE:-/dev/stdin}" +REPO_ROOT="${REPO_ROOT:-.}" + +STARTED=$(now_iso) +findings=$(mktemp); trap 'rm -f "$findings"' EXIT +diff_content=$(cat "$DIFF_FILE" 2>/dev/null || echo "") + +# QG-01: workflow with `ruff check` but no `ruff format --check` or `ty check` +if echo "$diff_content" | grep -qE '^\+\+\+ b/\.github/workflows/'; then + wf_content=$(echo "$diff_content" | awk '/^\+\+\+ b\/\.github\/workflows\// { flag=1; next } /^diff --git/ { flag=0 } flag && /^\+/ && !/^\+\+\+/ { print }') + if echo "$wf_content" | grep -q 'ruff check'; then + if ! echo "$wf_content" | grep -q 'ruff format'; then + emit_finding "QG-01" "FLAG" ".github/workflows/" 1 \ + "Workflow runs 'ruff check' but not 'ruff format --check' — mismatch with dev gate" "" >> "$findings" + fi + if ! echo "$wf_content" | grep -q 'ty check'; then + emit_finding "QG-02" "FLAG" ".github/workflows/" 1 \ + "Workflow runs 'ruff check' but not 'ty check' — incomplete type gate" "" >> "$findings" + fi + fi +fi + +status=$(status_from_findings < "$findings") +emit_report "quality-gate-parity" "$LANGUAGE" "$status" "$STARTED" < "$findings" diff --git a/.claude/scripts/check-secrets.sh b/.claude/scripts/check-secrets.sh new file mode 100755 index 00000000..8564b026 --- /dev/null +++ b/.claude/scripts/check-secrets.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# check-secrets.sh — detect secrets (AWS keys, JWTs, GitHub tokens, private keys, etc.) in added lines. +# All SEC-* rules are BLOCK_LOCKED (cannot be suppressed). +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/skill-self-skip.sh +source "$SCRIPT_DIR/lib/skill-self-skip.sh" + +LANGUAGE="${LANGUAGE:-python}" +DIFF_FILE="${DIFF_FILE:-/dev/stdin}" + +STARTED=$(now_iso) + +# Read diff (either from env-provided file or stdin) — extract added lines with file+line info +diff_content=$(cat "$DIFF_FILE" 2>/dev/null || echo "") + +if [ -z "$diff_content" ]; then + emit_report "secrets" "$LANGUAGE" "PASS" "$STARTED" <<< "" + exit 0 +fi + +findings=$(mktemp) +trap 'rm -f "$findings"' EXIT + +# Parse diff and scan each added line +echo "$diff_content" | awk ' + BEGIN { file=""; line=0 } + /^diff --git a\// { + file=$4 + sub(/^b\//, "", file) + line=0 + next + } + /^@@/ { + if (match($0, /\+[0-9]+/)) line=substr($0, RSTART+1, RLENGTH-1)+0 + next + } + /^\+/ && !/^\+\+\+/ { + print file "\t" line "\t" substr($0, 2) + line++ + next + } + /^ / { line++; next } +' | while IFS=$'\t' read -r file line_num content; do + [ -z "$file" ] && continue + # Self-review protection: skip skill files + if [ "$(is_skill_file "$file")" = "true" ]; then continue; fi + + # SEC-01: AWS Access Key + if echo "$content" | grep -qE 'AKIA[0-9A-Z]{16}'; then + emit_finding "SEC-01" "BLOCK" "$file" "$line_num" "AWS Access Key detected — remove immediately and rotate the key" "" >> "$findings" + fi + # SEC-02: Google API Key + if echo "$content" | grep -qE 'AIza[0-9A-Za-z_-]{35}'; then + emit_finding "SEC-02" "BLOCK" "$file" "$line_num" "Google API Key detected — remove and rotate" "" >> "$findings" + fi + # SEC-03: GitHub PAT + if echo "$content" | grep -qE 'gh[pousr]_[A-Za-z0-9_]{36,}'; then + emit_finding "SEC-03" "BLOCK" "$file" "$line_num" "GitHub PAT detected — remove and rotate" "" >> "$findings" + fi + # SEC-04: OpenAI key + if echo "$content" | grep -qE 'sk-[A-Za-z0-9]{20,}'; then + emit_finding "SEC-04" "BLOCK" "$file" "$line_num" "OpenAI-like API key detected — remove and rotate" "" >> "$findings" + fi + # SEC-05: Slack bot token + if echo "$content" | grep -qE 'xox[baprs]-[A-Za-z0-9-]+'; then + emit_finding "SEC-05" "BLOCK" "$file" "$line_num" "Slack token detected — remove and rotate" "" >> "$findings" + fi + # SEC-06: JWT + if echo "$content" | grep -qE 'eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}'; then + emit_finding "SEC-06" "BLOCK" "$file" "$line_num" "JWT token detected — remove and rotate" "" >> "$findings" + fi + # SEC-07: Private key header + if echo "$content" | grep -qE 'BEGIN (RSA |EC |OPENSSH |DSA |ENCRYPTED )?PRIVATE KEY'; then + emit_finding "SEC-07" "BLOCK" "$file" "$line_num" "Private key header detected — remove and rotate" "" >> "$findings" + fi + # SEC-08: BTP client_secret literal (heuristic: assignment with looks-like-secret value) + # Match client_secret= or clientsecret= with quoted values that look like real secrets + if echo "$content" | grep -qE '"clientsecret"\s*:\s*"[A-Za-z0-9+/=]{20,}"'; then + emit_finding "SEC-08" "BLOCK" "$file" "$line_num" "BTP client_secret literal detected — use secret resolver" "" >> "$findings" + fi +done + +# SEC-10: .env files in diff (not .env.example, .env.test, .env.sample) +env_lines=$(echo "$diff_content" | grep -E '^\+\+\+ b/\.env(\..+)?$' || true) +while IFS= read -r line; do + [ -z "$line" ] && continue + # extract path from "+++ b/" + path="${line#+++ b/}" + # skip allowed suffixes + case "$path" in + .env.example|.env.test|.env.sample|.env.template) continue ;; + .env|.env.*) emit_finding "SEC-10" "BLOCK" "$path" 1 ".env file committed — never commit .env; use .env.example" "" >> "$findings" ;; + esac +done <<< "$env_lines" + +status=$(status_from_findings < "$findings") +emit_report "secrets" "$LANGUAGE" "$status" "$STARTED" < "$findings" diff --git a/.claude/scripts/check-telemetry.sh b/.claude/scripts/check-telemetry.sh new file mode 100755 index 00000000..5d48fa43 --- /dev/null +++ b/.claude/scripts/check-telemetry.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# check-telemetry.sh — verify telemetry instrumentation. +# Python: @record_metrics on public *Client methods + emission tests. +# Java: Telemetry.executeWithTelemetry(...) wrapping + tests. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/json-emit.sh +source "$SCRIPT_DIR/lib/json-emit.sh" + +LANGUAGE="${LANGUAGE:-python}" +REPO_ROOT="${REPO_ROOT:-.}" +DIFF_FILE="${DIFF_FILE:-/dev/stdin}" + +STARTED=$(now_iso) +findings=$(mktemp) +trap 'rm -f "$findings"' EXIT + +diff_content=$(cat "$DIFF_FILE" 2>/dev/null || echo "") + +# Get list of client files newly added or modified +if [ "$LANGUAGE" = "python" ]; then + client_files=$(echo "$diff_content" | grep -oE '^\+\+\+ b/src/sap_cloud_sdk/[a-z_]+/.*Client\.py|^\+\+\+ b/src/sap_cloud_sdk/[a-z_]+/client\.py' | sed 's|^+++ b/||' | sort -u) +else + client_files=$(echo "$diff_content" | grep -oE '^\+\+\+ b/src/main/java/com/sap/cloud/sdk/[a-z_]+/.*Client\.java' | sed 's|^+++ b/||' | sort -u) +fi + +# Detect new decorator additions (added lines with @record_metrics for Python, executeWithTelemetry for Java) +if [ "$LANGUAGE" = "python" ]; then + new_decorators=$(echo "$diff_content" | grep -E '^\+[[:space:]]*@record_metrics' | wc -l | tr -d ' ') +else + new_decorators=$(echo "$diff_content" | grep -E '^\+.*Telemetry\.executeWithTelemetry' | wc -l | tr -d ' ') +fi + +# PY-TEL-02: For each changed client file, run AST check +if [ "$LANGUAGE" = "python" ] && [ -n "$client_files" ]; then + # shellcheck disable=SC2086 + python3 "$SCRIPT_DIR/lib/ast_python_checks.py" tel-02 $client_files 2>/dev/null >> "$findings" || true +fi + +# JV-TEL-02: For Java, grep-based check (executeWithTelemetry wrap around methods) +if [ "$LANGUAGE" = "java" ] && [ -n "$client_files" ]; then + while IFS= read -r f; do + [ -z "$f" ] && continue + full_path="$REPO_ROOT/$f" + [ -f "$full_path" ] || continue + # find public methods in the file + while IFS= read -r match; do + line_num="${match%%:*}" + # check the following 20 lines for executeWithTelemetry + 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" \ + "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) + done <<< "$client_files" +fi + +# PY-TEL-06 / JV-TEL-05: emission tests required when new decorator/wrapper added +if [ "$new_decorators" -gt 0 ]; then + if [ "$LANGUAGE" = "python" ]; then + # Find test files added/modified in same PR + test_files=$(echo "$diff_content" | grep -oE '^\+\+\+ b/tests/[a-z_]+/.*/test_.*\.py' | sed 's|^+++ b/||' | sort -u) + has_metric_assert=false + while IFS= read -r tf; do + [ -z "$tf" ] && continue + [ -f "$REPO_ROOT/$tf" ] || continue + if grep -q 'record_request_metric\|record_error_metric' "$REPO_ROOT/$tf"; then + has_metric_assert=true; break + fi + done <<< "$test_files" + if [ "$has_metric_assert" = "false" ]; then + emit_finding "PY-TEL-06" "BLOCK" "tests/" 1 \ + "New @record_metrics added ($new_decorators occurrences) but no test asserts record_request_metric was called" \ + "Add a test that mocks record_request_metric and asserts it was called with the expected Module/Operation" >> "$findings" + fi + fi +fi + +status=$(status_from_findings < "$findings") +emit_report "telemetry" "$LANGUAGE" "$status" "$STARTED" < "$findings" diff --git a/.claude/scripts/check-testing-depth.sh b/.claude/scripts/check-testing-depth.sh new file mode 100755 index 00000000..83dba8d6 --- /dev/null +++ b/.claude/scripts/check-testing-depth.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# check-testing-depth.sh — test names, tests-added checkbox truthfulness, integration test present. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/json-emit.sh" + +LANGUAGE="${LANGUAGE:-python}" +DIFF_FILE="${DIFF_FILE:-/dev/stdin}" +PR_BODY_FILE="${PR_BODY_FILE:-}" + +STARTED=$(now_iso) +findings=$(mktemp); trap 'rm -f "$findings"' EXIT + +diff_content=$(cat "$DIFF_FILE" 2>/dev/null || echo "") + +# TD-01: If PR title is fix: AND src/ changed AND no test file changed → FLAG +if [ "$LANGUAGE" = "python" ]; then + fix_commit=$(git log HEAD --format=%s -n 1 2>/dev/null | grep -qE '^fix' && echo yes || echo no) + src_changed=$(echo "$diff_content" | grep -qE 'src/sap_cloud_sdk/' && echo yes || echo no) + test_changed=$(echo "$diff_content" | grep -qE 'tests?/.*/test_' && echo yes || echo no) + if [ "$fix_commit" = "yes" ] && [ "$src_changed" = "yes" ] && [ "$test_changed" = "no" ]; then + emit_finding "TD-01" "FLAG" "tests/" 1 \ + "Bug-fix PR touches src/ but no test files changed" \ + "Add a focused unit test that reproduces the bug and asserts the fix" >> "$findings" + fi + + # TD-10: New module → integration test required + new_modules=$(echo "$diff_content" | awk '/^diff --git/ { flag=0 } /^new file mode/ { flag=1 } flag && /^\+\+\+ b\/src\/sap_cloud_sdk\/[a-z_]+\/[^/]+\.py/ { print }' | grep -oE 'src/sap_cloud_sdk/[a-z_]+/' | sed 's|src/sap_cloud_sdk/||; s|/$||' | sort -u) + while IFS= read -r mod; do + [ -z "$mod" ] || [ "$mod" = "core" ] && continue + has_integration=$(echo "$diff_content" | grep -qE "tests/$mod/integration/" && echo yes || echo no) + if [ "$has_integration" = "no" ]; then + emit_finding "TD-10" "BLOCK" "tests/$mod/integration/" 1 \ + "New module '$mod' has no integration test" "" >> "$findings" + fi + done <<< "$new_modules" +fi + +# TD-checkbox: PR body says "tests added" but no test files touched +if [ -n "$PR_BODY_FILE" ] && [ -f "$PR_BODY_FILE" ]; then + body=$(cat "$PR_BODY_FILE") + if echo "$body" | grep -qE '\-[[:space:]]*\[[xX]\][[:space:]].*(added|updated).*tests'; then + if ! echo "$diff_content" | grep -qE 'diff --git a/(tests?/|src/test/)'; then + emit_finding "TD-checkbox" "FLAG" "PR_BODY" 1 \ + "PR body ticks 'added tests' checkbox but no test files changed" "" >> "$findings" + fi + fi +fi + +status=$(status_from_findings < "$findings") +emit_report "testing-depth" "$LANGUAGE" "$status" "$STARTED" < "$findings" diff --git a/.claude/scripts/check-versioning.sh b/.claude/scripts/check-versioning.sh new file mode 100755 index 00000000..8828834d --- /dev/null +++ b/.claude/scripts/check-versioning.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# check-versioning.sh — SemVer bump + BREAKING family (BREAKING-01..04). +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/json-emit.sh" +source "$SCRIPT_DIR/lib/predicates.sh" + +LANGUAGE="${LANGUAGE:-python}" +REPO_ROOT="${REPO_ROOT:-.}" +DIFF_FILE="${DIFF_FILE:-/dev/stdin}" +PR_BODY_FILE="${PR_BODY_FILE:-}" +BASE_SHA="${BASE_SHA:-}" +HEAD_SHA="${HEAD_SHA:-HEAD}" +BREAKING_JSON="${BREAKING_JSON:-}" # pre-computed via breaking-detector.py + +STARTED=$(now_iso) +findings=$(mktemp); trap 'rm -f "$findings"' EXIT + +diff_content=$(cat "$DIFF_FILE" 2>/dev/null || echo "") + +# Detect version-bump line change +if [ "$LANGUAGE" = "python" ]; then + version_bumped=$(echo "$diff_content" | grep -E '^\+version\s*=' | head -1) + version_removed=$(echo "$diff_content" | grep -E '^-version\s*=' | head -1) +else + version_bumped=$(echo "$diff_content" | grep -E '^\+\s*' | head -1) + version_removed=$(echo "$diff_content" | grep -E '^-\s*' | head -1) +fi + +# src/ changes present? +if [ "$LANGUAGE" = "python" ]; then + src_changed=$(echo "$diff_content" | grep -qE 'diff --git a/src/sap_cloud_sdk/' && echo yes || echo no) +else + src_changed=$(echo "$diff_content" | grep -qE 'diff --git a/src/main/java/' && echo yes || echo no) +fi + +# commit types (may not find any → allow non-zero exit under set -e) +commit_types=$(has_commit_type "${BASE_SHA:-HEAD~10}" "$HEAD_SHA" "feat,feat!,fix,fix!" 2>/dev/null || echo "false") + +# BREAKING detector output +breaking_detected="false" +if [ -n "$BREAKING_JSON" ] && [ -f "$BREAKING_JSON" ]; then + breaking_detected=$(jq -r '.breaking_detected' "$BREAKING_JSON" 2>/dev/null || echo "false") +fi + +is_feat=$(has_commit_type "${BASE_SHA:-HEAD~10}" "$HEAD_SHA" "feat,feat!" 2>/dev/null || echo "false") + +# VER-01: src/ change without bump — only fires on feat OR breaking +if [ "$src_changed" = "yes" ] && [ -z "$version_bumped" ]; then + if [ "$is_feat" = "true" ] || [ "$breaking_detected" = "true" ]; then + emit_finding "VER-01" "BLOCK" "pyproject.toml" 1 \ + "Feature or breaking change detected but version not bumped" \ + "Bump MINOR version for feat/API change; MAJOR for breaking" >> "$findings" + fi +fi + +# BREAKING-01: if breaking, check PR body has proper declarations +if [ "$breaking_detected" = "true" ]; then + # collect requirements + has_bang=$(has_commit_type "${BASE_SHA:-HEAD~10}" "$HEAD_SHA" "feat!,fix!" || echo "false") + has_bump=$([ -n "$version_bumped" ] && echo "true" || echo "false") + + pr_body="" + if [ -n "$PR_BODY_FILE" ] && [ -f "$PR_BODY_FILE" ]; then + pr_body=$(cat "$PR_BODY_FILE") + fi + has_breaking_section="false" + if echo "$pr_body" | grep -qE '^##[[:space:]]+Breaking Changes' ; then + # section must have non-empty content (not "N/A" or "None" alone) + # Use awk to extract from "## Breaking Changes" to next "## " heading, drop the header line itself + section=$(echo "$pr_body" | awk ' + /^##[[:space:]]+Breaking Changes/ { flag=1; next } + flag && /^##[[:space:]]+[A-Z]/ { exit } + flag { print } + ' | tr -d '[:space:]') + if [ -n "$section" ] && ! echo "$section" | grep -qEi '^(N/A|None|none|--)*$'; then + has_breaking_section="true" + fi + fi + has_checkbox=$(echo "$pr_body" | grep -qE '\-[[:space:]]*\[[xX]\][[:space:]]+Breaking change' && echo "true" || echo "false") + + # if ANY of the 4 requirements is missing → BLOCK + missing="" + [ "$has_bang" != "true" ] && missing="$missing commit-!:-prefix" + [ "$has_bump" != "true" ] && missing="$missing version-bump" + [ "$has_breaking_section" != "true" ] && missing="$missing PR-body-Breaking-Changes-section" + [ "$has_checkbox" != "true" ] && missing="$missing PR-body-checkbox" + + if [ -n "$missing" ]; then + emit_finding "BREAKING-01" "BLOCK" "PR_METADATA" 1 \ + "Breaking change detected — missing declarations:$missing" \ + "Add all 4: (a) feat!:/fix!: commit prefix (b) ## Breaking Changes section (c) checkbox ticked (d) version bump" >> "$findings" + + # BREAKING-02: partial declaration (some declared, others not) + declared_count=0 + [ "$has_bang" = "true" ] && declared_count=$((declared_count+1)) + [ "$has_bump" = "true" ] && declared_count=$((declared_count+1)) + [ "$has_breaking_section" = "true" ] && declared_count=$((declared_count+1)) + [ "$has_checkbox" = "true" ] && declared_count=$((declared_count+1)) + if [ "$declared_count" -gt 0 ] && [ "$declared_count" -lt 4 ]; then + emit_finding "BREAKING-02" "BLOCK" "PR_METADATA" 1 \ + "Breaking-change metadata is inconsistent ($declared_count/4 declared)" \ + "All 4 declarations must agree — reconcile or fully retract" >> "$findings" + fi + fi +fi + +status=$(status_from_findings < "$findings") +emit_report "versioning" "$LANGUAGE" "$status" "$STARTED" < "$findings" diff --git a/.claude/scripts/lib/apply-suppression.sh b/.claude/scripts/lib/apply-suppression.sh new file mode 100755 index 00000000..79b39c28 --- /dev/null +++ b/.claude/scripts/lib/apply-suppression.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# lib/apply-suppression.sh — filter findings against suppression comments in source. +# Called after a check produces its JSON report; reads suppression tuples and drops findings. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# collect_suppressions_for_files ... +# Emits "::" tuples for all suppression comments found. +collect_suppressions_for_files() { + for f in "$@"; do + [ -f "$f" ] || continue + bash "$SCRIPT_DIR/suppression.sh" parse_line "$f" 2>/dev/null || true + bash "$SCRIPT_DIR/suppression.sh" parse_file "$f" 2>/dev/null || true + done +} + +# apply_to_report → prints filtered report +apply_to_report() { + local report="$1" supp_file="$2" + local kept_count=0 suppressed_count=0 + local input; input=$(cat "$report") + local n; n=$(echo "$input" | jq '.findings | length') + if [ "$n" -eq 0 ]; then + echo "$input"; return + fi + + local kept=() + local i=0 + while [ "$i" -lt "$n" ]; do + local finding rule file line + finding=$(echo "$input" | jq -c ".findings[$i]") + rule=$(echo "$finding" | jq -r '.rule') + file=$(echo "$finding" | jq -r '.file') + line=$(echo "$finding" | jq -r '.line') + + local suppressed + suppressed=$(bash "$SCRIPT_DIR/suppression.sh" is_suppressed "$rule" "$file" "$line" "$supp_file" 2>/dev/null || echo "false") + + if [ "$suppressed" = "true" ]; then + suppressed_count=$((suppressed_count + 1)) + else + kept+=("$finding") + kept_count=$((kept_count + 1)) + fi + i=$((i + 1)) + done + + local kept_json + if [ ${#kept[@]} -eq 0 ]; then + kept_json="[]" + else + kept_json=$(printf '%s\n' "${kept[@]}" | jq -s -c '.') + fi + + echo "$input" | jq \ + --argjson kept "$kept_json" \ + --argjson suppressed "$suppressed_count" \ + '.findings = $kept | .summary.suppressed_count = $suppressed' +} + +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + cmd="${1:-}"; shift || true + case "$cmd" in + collect) collect_suppressions_for_files "$@" ;; + apply) apply_to_report "$@" ;; + *) echo "Usage: apply-suppression.sh {collect|apply} args" >&2; exit 2 ;; + esac +fi diff --git a/.claude/scripts/lib/ast_python_checks.py b/.claude/scripts/lib/ast_python_checks.py new file mode 100755 index 00000000..d0039f59 --- /dev/null +++ b/.claude/scripts/lib/ast_python_checks.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""AST-based checks for Python source files. + +Called from bash check-*.sh scripts. Reads source files, emits JSONL findings +to stdout (one JSON object per line). + +Usage: + python3 ast-python-checks.py [ ...] + +Where is one of: + el-01, el-02 exception chaining / swallow + tel-02, tel-06 telemetry decorators / tests + pt-01, pt-08, pt-11 patterns + con-01 repeated string literals +""" + +from __future__ import annotations + +import ast +import json +import sys +from pathlib import Path + + +def emit( + rule: str, severity: str, file: str, line: int, message: str, suggestion: str = "" +) -> None: + print( + json.dumps( + { + "rule": rule, + "severity": severity, + "file": file, + "line": line, + "message": message, + "suggestion": suggestion, + } + ) + ) + + +def parse_file(path: str) -> ast.Module | None: + try: + source = Path(path).read_text() + return ast.parse(source, filename=path) + except (SyntaxError, UnicodeDecodeError, FileNotFoundError): + return None + + +# ---------- PY-EL-01: raise X from e when raising DIFFERENT exception ---------- + + +def check_el_01(path: str, tree: ast.Module) -> None: + for node in ast.walk(tree): + if not isinstance(node, ast.ExceptHandler): + continue + handler_name = node.name # the caught exception variable, may be None + for inner in ast.walk(node): + if not isinstance(inner, ast.Raise): + continue + # bare `raise` — PASS (preserves cause) + if inner.exc is None: + continue + # `raise e` where e is the handler var — PASS (same exception) + if ( + isinstance(inner.exc, ast.Name) + and handler_name + and inner.exc.id == handler_name + ): + continue + # raising a *call* to the same var, e.g. `raise e()` (rare) — PASS + if ( + isinstance(inner.exc, ast.Call) + and isinstance(inner.exc.func, ast.Name) + and handler_name + and inner.exc.func.id == handler_name + ): + continue + # different exception without `from e` → FLAG + if inner.cause is None: + emit( + "PY-EL-01", + "FLAG", + path, + inner.lineno, + "Raising different exception inside except — use `raise X from e` to preserve cause", + ) + + +# ---------- PY-EL-02: except body must end with raise or explicit suppression ---------- + + +def check_el_02(path: str, tree: ast.Module) -> None: + for node in ast.walk(tree): + if not isinstance(node, ast.ExceptHandler): + continue + if not node.body: + continue + last = node.body[-1] + # last statement is Raise → PASS + if isinstance(last, ast.Raise): + continue + # last statement is Return of a variable named e / err / exc → likely intentional propagation → PASS + if ( + isinstance(last, ast.Return) + and isinstance(last.value, ast.Name) + and last.value.id in {"e", "err", "exc"} + ): + continue + # explicit `pass` and only `pass` in body → obvious swallow → FLAG + emit( + "PY-EL-02", + "FLAG", + path, + node.lineno, + "except block does not end with `raise` — swallowing? Add re-raise or comment justifying suppression", + ) + + +# ---------- PY-TEL-02: public *Client methods have @record_metrics ---------- + + +def check_tel_02(path: str, tree: ast.Module) -> None: + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + if not node.name.endswith("Client"): + continue + for item in node.body: + if not isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if item.name.startswith("_"): + continue + has_record = any( + ( + isinstance(d, ast.Call) + and isinstance(d.func, ast.Name) + and d.func.id == "record_metrics" + ) + or (isinstance(d, ast.Name) and d.id == "record_metrics") + for d in item.decorator_list + ) + if not has_record: + emit( + "PY-TEL-02", + "BLOCK", + path, + item.lineno, + f"Public method `{node.name}.{item.name}` missing @record_metrics decorator", + ) + + +# ---------- PY-TEL-06: test files that assert record_request_metric calls ---------- + + +def check_tel_06_test_asserts(path: str, tree: ast.Module) -> bool: + """Returns True if the test file asserts record_request_metric was called.""" + src = ast.dump(tree) + return "record_request_metric" in src + + +# ---------- PY-PT-08: public functions/methods have type hints ---------- + + +def check_pt_08(path: str, tree: ast.Module) -> None: + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + # skip private functions + if node.name.startswith("_") and node.name != "__init__": + continue + # check return annotation + if node.returns is None and node.name != "__init__": + emit( + "PY-PT-08", + "FLAG", + path, + node.lineno, + f"Public function `{node.name}` missing return type annotation", + ) + # check arg annotations (excluding self) + args = node.args.args + for arg in args: + if arg.arg == "self": + continue + if arg.annotation is None: + emit( + "PY-PT-08", + "FLAG", + path, + node.lineno, + f"Parameter `{arg.arg}` of `{node.name}` missing type annotation", + ) + + +# ---------- PY-CON-01: repeated string literals ---------- + + +def check_con_01( + path: str, tree: ast.Module, threshold: int = 3, min_len: int = 4 +) -> None: + 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: + continue + if v.startswith(("http", "test-", "SPDX", "@example")): + continue # skip URLs, test fixtures, doc markers + counts.setdefault(v, []).append(node.lineno) + for literal, lines in counts.items(): + if len(lines) >= threshold: + emit( + "PY-CON-01", + "FLAG", + path, + lines[0], + f"String literal {literal!r} appears {len(lines)}× — extract module-level constant", + suggestion=f"e.g., _CONSTANT_NAME = {literal!r}", + ) + + +# ---------- PY-PT-01: create_client factory exists ---------- + + +def check_pt_01(module_dir: str) -> bool: + """Check if module has create_client() function (returns True if found).""" + p = Path(module_dir) + if not p.is_dir(): + return False + # scan module-level defs across all .py files + for py in p.glob("*.py"): + if py.name.startswith("_") and py.name != "__init__.py": + continue + tree = parse_file(str(py)) + if tree is None: + continue + for node in tree.body: + if ( + isinstance(node, ast.FunctionDef) + and node.name.startswith("create_") + and node.name.endswith("client") + ): + return True + return False + + +# ---------- __all__ extraction (used by breaking-detector.py) ---------- + + +def extract_all(tree: ast.Module) -> list[str]: + """Extract the __all__ list from a module AST.""" + for node in tree.body: + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "__all__": + if isinstance(node.value, (ast.List, ast.Tuple)): + return [ + elt.value + for elt in node.value.elts + if isinstance(elt, ast.Constant) and isinstance(elt.value, str) + ] + return [] + + +def extract_public_class_methods( + tree: ast.Module, +) -> dict[str, dict[str, tuple[list[str], str | None]]]: + """Return {ClassName: {method_name: (arg_names, return_annotation_repr)}}.""" + result: dict[str, dict[str, tuple[list[str], str | None]]] = {} + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + if node.name.startswith("_"): + continue + methods: dict[str, tuple[list[str], str | None]] = {} + for item in node.body: + if not isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if item.name.startswith("_") and item.name != "__init__": + continue + args = [a.arg for a in item.args.args if a.arg != "self"] + ret = ast.unparse(item.returns) if item.returns else None + methods[item.name] = (args, ret) + if methods: + result[node.name] = methods + return result + + +def extract_dataclass_fields(tree: ast.Module) -> dict[str, list[str]]: + """Return {DataclassName: [field_names]} for classes decorated with @dataclass.""" + result: dict[str, list[str]] = {} + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + is_dc = any( + (isinstance(d, ast.Name) and d.id == "dataclass") + or ( + isinstance(d, ast.Call) + and isinstance(d.func, ast.Name) + and d.func.id == "dataclass" + ) + for d in node.decorator_list + ) + if not is_dc: + continue + fields: list[str] = [] + for item in node.body: + if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name): + fields.append(item.target.id) + if fields: + result[node.name] = fields + return result + + +# ---------- dispatcher ---------- + +CHECKS = { + "el-01": check_el_01, + "el-02": check_el_02, + "tel-02": check_tel_02, + "pt-08": check_pt_08, + "con-01": check_con_01, +} + + +def main(argv: list[str]) -> int: + if len(argv) < 3: + print( + "Usage: ast-python-checks.py [ ...]", + file=sys.stderr, + ) + return 2 + check_name = argv[1] + files = argv[2:] + + if check_name not in CHECKS: + print(f"ERROR: unknown check {check_name}", file=sys.stderr) + return 2 + + fn = CHECKS[check_name] + for f in files: + tree = parse_file(f) + if tree is None: + continue + fn(f, tree) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.claude/scripts/lib/baseline.sh b/.claude/scripts/lib/baseline.sh new file mode 100755 index 00000000..4fece150 --- /dev/null +++ b/.claude/scripts/lib/baseline.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# lib/baseline.sh — Apply baseline exemptions to a findings JSON. +# Reads baseline.json + a check-report JSON, outputs the filtered report. +set -euo pipefail + +# is_exempted rule file baseline_json → prints "true" | "false" +# Baseline schema: {exemptions: {"": {scope: "repo|module:|file", files_exempted_glob: "", file_snapshot: {...}}}} +is_exempted() { + local rule="$1" file="$2" baseline="$3" + local entry; entry=$(echo "$baseline" | jq -r --arg rule "$rule" '.exemptions[$rule] // empty') + if [ -z "$entry" ]; then echo "false"; return; fi + + local scope glob + scope=$(echo "$entry" | jq -r '.scope // "repo"') + glob=$(echo "$entry" | jq -r '.files_exempted_glob // "**"') + + case "$scope" in + repo) + echo "true" + ;; + file) + # only exempt if file is in file_snapshot AND has not regressed (LOC check) + local snapshot; snapshot=$(echo "$entry" | jq -r --arg f "$file" '.file_snapshot[$f] // empty') + if [ -n "$snapshot" ]; then echo "true"; else echo "false"; fi + ;; + module:*) + local mod="${scope#module:}" + # naive: match path contains "/$mod/" or starts with "$mod/" + if [[ "$file" == *"/${mod}/"* || "$file" == "${mod}/"* ]]; then + echo "true" + else + echo "false" + fi + ;; + *) + echo "false" + ;; + esac +} + +# apply_baseline_to_report baseline_file report_file → prints filtered report to stdout, +# suppressed count to stderr +apply_baseline_to_report() { + local baseline_file="$1" report_file="$2" + local baseline; baseline=$(cat "$baseline_file" 2>/dev/null || echo '{"exemptions":{}}') + + local report; report=$(cat "$report_file") + local kept=() suppressed=0 + + local n; n=$(echo "$report" | jq '.findings | length') + local i=0 + while [ "$i" -lt "$n" ]; do + local finding rule file exempt + finding=$(echo "$report" | jq -c ".findings[$i]") + rule=$(echo "$finding" | jq -r '.rule') + file=$(echo "$finding" | jq -r '.file') + exempt=$(is_exempted "$rule" "$file" "$baseline") + if [ "$exempt" = "true" ]; then + suppressed=$((suppressed + 1)) + else + kept+=("$finding") + fi + i=$((i + 1)) + done + + echo "$report" | jq --argjson kept "$(printf '%s\n' "${kept[@]:-}" | jq -s -c '.')" \ + --argjson suppressed "$suppressed" \ + '.findings = $kept | .summary.suppressed_by_baseline = $suppressed' +} + +# When sourced, expose functions. When executed, run apply_baseline_to_report. +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + apply_baseline_to_report "$@" +fi diff --git a/.claude/scripts/lib/breaking-detector.py b/.claude/scripts/lib/breaking-detector.py new file mode 100755 index 00000000..ae97fb5a --- /dev/null +++ b/.claude/scripts/lib/breaking-detector.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +"""Breaking-change detector. + +Compares AST of base commit vs HEAD to find: + - api_removal_detected removed from __all__ + - method_deletion_on_public_class + - dataclass_field_deletion + - public_method_signature_change + - enum_value_deletion + - exception_hierarchy_change + +Output: JSON on stdout: +{ + "breaking_detected": true|false, + "kinds": [...], + "details": [{"kind": "...", "symbol": "...", "file": "...", "line": N}, ...] +} +""" + +from __future__ import annotations + +import ast +import json +import subprocess +import sys +from pathlib import Path + +# Import from same directory +sys.path.insert(0, str(Path(__file__).parent)) +from ast_python_checks import ( + extract_all, + extract_public_class_methods, + extract_dataclass_fields, +) # noqa: E402 + + +def git_show(sha: str, path: str) -> str | None: + """Read file content at a given commit. Returns None if not available.""" + # Try direct git show first + try: + out = subprocess.run( + ["git", "show", f"{sha}:{path}"], + capture_output=True, + text=True, + check=False, + ) + if out.returncode == 0: + return out.stdout + except Exception: + pass + # Fallback: try to fetch the object first if it's missing + try: + subprocess.run( + ["git", "fetch", "--quiet", "origin", sha], + capture_output=True, + text=True, + check=False, + timeout=10, + ) + out = subprocess.run( + ["git", "show", f"{sha}:{path}"], + capture_output=True, + text=True, + check=False, + ) + if out.returncode == 0: + return out.stdout + except Exception: + pass + return None + + +def read_head_file(path: str) -> str | None: + """Read file content from HEAD (working tree).""" + try: + return Path(path).read_text() + except (OSError, UnicodeDecodeError): + return None + + +def parse_source(source: str, filename: str = "") -> ast.Module | None: + try: + return ast.parse(source, filename=filename) + except SyntaxError: + return None + + +def files_changed(base: str, head: str) -> list[str]: + out = subprocess.run( + ["git", "diff", "--name-only", f"{base}..{head}", "--", "*.py"], + capture_output=True, + text=True, + check=False, + ) + return [f for f in out.stdout.strip().split("\n") if f] + + +def detect(base: str, head: str) -> dict: + details: list[dict] = [] + kinds: set[str] = set() + + changed = files_changed(base, head) + + for file in changed: + base_src = git_show(base, file) + head_src = git_show(head, file) if head != "HEAD" else read_head_file(file) + if head_src is None and Path(file).exists(): + head_src = read_head_file(file) + if head_src is None: + # file deleted + if base_src: + base_tree = parse_source(base_src, file) + if base_tree: + for name in extract_all(base_tree): + details.append( + { + "kind": "api_removal_detected", + "symbol": name, + "file": file, + "line": 0, + } + ) + kinds.add("api_removal_detected") + continue + + base_tree = parse_source(base_src, file) if base_src else None + head_tree = parse_source(head_src, file) + if base_tree is None or head_tree is None: + continue + + # __all__ removals + base_all = set(extract_all(base_tree)) + head_all = set(extract_all(head_tree)) + removed = base_all - head_all + for name in removed: + details.append( + { + "kind": "api_removal_detected", + "symbol": name, + "file": file, + "line": 0, + } + ) + kinds.add("api_removal_detected") + + # public class method removals + signature changes + base_classes = extract_public_class_methods(base_tree) + head_classes = extract_public_class_methods(head_tree) + for cls_name, base_methods in base_classes.items(): + head_methods = head_classes.get(cls_name, {}) + for mname, (base_args, base_ret) in base_methods.items(): + if mname not in head_methods: + details.append( + { + "kind": "method_deletion_on_public_class", + "symbol": f"{cls_name}.{mname}", + "file": file, + "line": 0, + } + ) + kinds.add("method_deletion_on_public_class") + else: + head_args, head_ret = head_methods[mname] + if base_args != head_args: + details.append( + { + "kind": "public_method_signature_change", + "symbol": f"{cls_name}.{mname}", + "file": file, + "line": 0, + "base_args": base_args, + "head_args": head_args, + } + ) + kinds.add("public_method_signature_change") + + # dataclass field removals + base_dcs = extract_dataclass_fields(base_tree) + head_dcs = extract_dataclass_fields(head_tree) + for dc_name, base_fields in base_dcs.items(): + head_fields = set(head_dcs.get(dc_name, [])) + for field in base_fields: + if field not in head_fields: + details.append( + { + "kind": "dataclass_field_deletion_on_public_model", + "symbol": f"{dc_name}.{field}", + "file": file, + "line": 0, + } + ) + kinds.add("dataclass_field_deletion_on_public_model") + + # enum value removals (best-effort: str Enum classes) + base_enums = _extract_enums(base_tree) + head_enums = _extract_enums(head_tree) + for enum_name, base_values in base_enums.items(): + head_values = set(head_enums.get(enum_name, [])) + for val in base_values: + if val not in head_values: + details.append( + { + "kind": "enum_value_deletion_on_public_enum", + "symbol": f"{enum_name}.{val}", + "file": file, + "line": 0, + } + ) + kinds.add("enum_value_deletion_on_public_enum") + + return { + "breaking_detected": bool(kinds), + "kinds": sorted(kinds), + "details": details, + } + + +def _extract_enums(tree: ast.Module) -> dict[str, list[str]]: + result: dict[str, list[str]] = {} + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + if node.name.startswith("_"): + continue + # heuristic: base class named "Enum" or "IntEnum" or has str, Enum bases + is_enum = any( + (isinstance(b, ast.Name) and "Enum" in b.id) + or (isinstance(b, ast.Attribute) and "Enum" in b.attr) + for b in node.bases + ) + if not is_enum: + continue + values: list[str] = [] + for item in node.body: + if isinstance(item, ast.Assign): + for tgt in item.targets: + if isinstance(tgt, ast.Name): + values.append(tgt.id) + result[node.name] = values + return result + + +def main(argv: list[str]) -> int: + if len(argv) < 3: + # default to comparing merge-base of main..HEAD + base = ( + subprocess.run( + ["git", "merge-base", "origin/main", "HEAD"], + capture_output=True, + text=True, + check=False, + ).stdout.strip() + or "HEAD~1" + ) + head = "HEAD" + else: + base, head = argv[1], argv[2] + + result = detect(base, head) + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.claude/scripts/lib/detect-language.sh b/.claude/scripts/lib/detect-language.sh new file mode 100755 index 00000000..d3f9e26c --- /dev/null +++ b/.claude/scripts/lib/detect-language.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# lib/detect-language.sh — detect Python vs Java repo shape. +set -euo pipefail + +detect_language() { + local root="${1:-.}" + if [ -f "$root/pyproject.toml" ] && [ -f "$root/pom.xml" ]; then + echo "ERROR: both pyproject.toml AND pom.xml found — cannot auto-detect" >&2 + exit 3 + fi + if [ -f "$root/pyproject.toml" ]; then + if [ -d "$root/src/sap_cloud_sdk" ] || [ -d "$root/src" ]; then + echo "python"; return + fi + fi + if [ -f "$root/pom.xml" ]; then + if [ -d "$root/src/main/java" ]; then + echo "java"; return + fi + fi + echo "unknown" +} + +detect_language "$@" diff --git a/.claude/scripts/lib/detect-modules.sh b/.claude/scripts/lib/detect-modules.sh new file mode 100755 index 00000000..87fad9bd --- /dev/null +++ b/.claude/scripts/lib/detect-modules.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# lib/detect-modules.sh — extract module names from a diff. +set -euo pipefail + +# Usage: detect-modules.sh < diff.patch +lang="${1:-python}" +if [ "$lang" = "python" ]; then + grep -oE '(src/sap_cloud_sdk|src/)[a-z_][a-z_0-9]*/' 2>/dev/null | \ + sed -E 's|src/sap_cloud_sdk/||; s|src/||; s|/$||' | \ + grep -v '^core$' | grep -v '^\s*$' | sort -u +elif [ "$lang" = "java" ]; then + grep -oE 'src/main/java/com/sap/cloud/sdk/[a-z_][a-z_0-9]*/' 2>/dev/null | \ + sed 's|src/main/java/com/sap/cloud/sdk/||; s|/$||' | \ + grep -v '^core$' | sort -u +else + echo "ERROR: unknown language $lang" >&2; exit 2 +fi diff --git a/.claude/scripts/lib/diff-added-lines.sh b/.claude/scripts/lib/diff-added-lines.sh new file mode 100755 index 00000000..cce3788e --- /dev/null +++ b/.claude/scripts/lib/diff-added-lines.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# lib/diff-added-lines.sh — Emit file:line pairs that are in the added set (+) of a diff. +# Reads unified diff from stdin, outputs "path:linenumber" one per line (right-side lines only). +# Automatically excludes the skill's own files (self-review protection). +set -euo pipefail + +awk ' + BEGIN { file=""; line=0; skip=0 } + /^diff --git a\// { + # Field 4 is "b/" — take it directly (regex was buggy with paths containing "b/") + file = $4 + sub(/^b\//, "", file) + line = 0 + # Self-review protection: skip skill files + skip = 0 + if (file ~ /^\.claude\//) skip = 1 + if (file ~ /^tests\/sdk-review\//) skip = 1 + if (file ~ /^\.github\/workflows\/sdk-/) skip = 1 + if (file ~ /^docs\/PR-REVIEW\.md$/) skip = 1 + if (file ~ /^docs\/BRANCH-PROTECTION-SETUP\.md$/) skip = 1 + next + } + /^\+\+\+ / { next } + /^--- / { next } + /^@@/ { + # hunk header: @@ -old,+new,newcount @@ + if (match($0, /\+[0-9]+/)) { + line = substr($0, RSTART+1, RLENGTH-1) + 0 + } + next + } + /^\+/ && !/^\+\+\+/ { + if (!skip && file != "" && line > 0) print file ":" line + line++ + next + } + /^-/ && !/^---/ { + # deletion line — do not increment right-side counter + next + } + /^ / { + # context line — increment right-side counter but do not print + line++ + next + } +' "$@" diff --git a/.claude/scripts/lib/github-api.sh b/.claude/scripts/lib/github-api.sh new file mode 100755 index 00000000..2808d92c --- /dev/null +++ b/.claude/scripts/lib/github-api.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# lib/github-api.sh — gh CLI wrapper for review posting. +set -euo pipefail + +readonly SDK_REVIEW_MARKER_PREFIX="' + +# check_gh_auth → exit 0 if authed, exit 2 with message otherwise +check_gh_auth() { + local hostname="${1:-github.com}" + if ! gh auth status --hostname "$hostname" > /dev/null 2>&1; then + echo "ERROR: gh CLI not authenticated for $hostname. Run: gh auth login --hostname $hostname" >&2 + exit 2 + fi +} + +# detect_hostname → prints "github.com" or "github.tools.sap" from git remote +detect_hostname() { + local remote_url + remote_url=$(git remote get-url origin 2>/dev/null || echo "") + case "$remote_url" in + *github.tools.sap*) echo "github.tools.sap" ;; + *github.com*) echo "github.com" ;; + *) echo "github.com" ;; + esac +} + +# detect_owner_repo → prints "owner/repo" from git remote +detect_owner_repo() { + local remote_url + remote_url=$(git remote get-url origin 2>/dev/null || echo "") + # strip protocol/user prefix and .git suffix + remote_url="${remote_url#*github.com/}" + remote_url="${remote_url#*github.tools.sap/}" + remote_url="${remote_url#*://}" + remote_url="${remote_url%.git}" + echo "$remote_url" +} + +# get_pr_head_sha → prints the head SHA +get_pr_head_sha() { + local pr="$1" + gh pr view "$pr" --json headRefOid -q .headRefOid +} + +# list_bot_review_comments → outputs "id\tbody_marker" for each review-comment we've posted +list_bot_review_comments() { + local pr="$1" + local owner_repo; owner_repo=$(detect_owner_repo) + gh api "repos/${owner_repo}/pulls/${pr}/comments" --paginate 2>/dev/null | \ + jq -r --arg m "$SDK_REVIEW_MARKER_PREFIX" '.[] | select(.body | contains($m)) | "\(.id)\t\(.body[0:120])"' || true +} + +# list_bot_issue_comments → outputs "id" for each issue-comment (summary) we've posted +list_bot_issue_comments() { + local pr="$1" + local owner_repo; owner_repo=$(detect_owner_repo) + gh api "repos/${owner_repo}/issues/${pr}/comments" --paginate 2>/dev/null | \ + jq -r --arg m "$SUMMARY_MARKER" '.[] | select(.body | contains($m)) | .id' || true +} + +# delete_prior_bot_artifacts — idempotency: remove all sdk-review:v1 comments before posting new +delete_prior_bot_artifacts() { + local pr="$1" + local owner_repo; owner_repo=$(detect_owner_repo) + + # review-comments (inline) + list_bot_review_comments "$pr" | awk -F'\t' '{print $1}' | while read -r id; do + [ -n "$id" ] && gh api -X DELETE "repos/${owner_repo}/pulls/comments/${id}" > /dev/null 2>&1 || true + done + + # issue-comments (summary) + list_bot_issue_comments "$pr" | while read -r id; do + [ -n "$id" ] && gh api -X DELETE "repos/${owner_repo}/issues/comments/${id}" > /dev/null 2>&1 || true + done +} + +# post_inline_comment +post_inline_comment() { + local pr="$1" sha="$2" path="$3" line="$4" body="$5" + local owner_repo; owner_repo=$(detect_owner_repo) + gh api "repos/${owner_repo}/pulls/${pr}/comments" \ + -F body="$body" \ + -F commit_id="$sha" \ + -F path="$path" \ + -F line="$line" \ + -F side="RIGHT" > /dev/null 2>&1 || \ + # If line is not in diff, fall back to issue comment with file/line citation + gh api "repos/${owner_repo}/issues/${pr}/comments" \ + -F body="$body + +_(originally intended as inline on \`$path:$line\` but that line is not in the diff)_" > /dev/null +} + +# post_summary_comment +post_summary_comment() { + local pr="$1" body="$2" + local owner_repo; owner_repo=$(detect_owner_repo) + gh api "repos/${owner_repo}/issues/${pr}/comments" -F body="$body" > /dev/null +} + +# post_or_update_check_run <summary> +post_or_update_check_run() { + local sha="$1" conclusion="$2" title="$3" summary="$4" + local owner_repo; owner_repo=$(detect_owner_repo) + local check_name="sdk-module-review" + local external_id="sdk-review-v1-${sha}" + + # try to find existing run for same SHA + name + local existing + existing=$(gh api "repos/${owner_repo}/commits/${sha}/check-runs?check_name=${check_name}" 2>/dev/null | \ + jq -r '.check_runs[0].id // empty' || true) + + if [ -n "$existing" ]; then + gh api -X PATCH "repos/${owner_repo}/check-runs/${existing}" \ + -F status="completed" \ + -F conclusion="$conclusion" \ + -F "output[title]=$title" \ + -F "output[summary]=$summary" > /dev/null 2>&1 || { + echo "WARN: could not update check-run (likely missing checks:write scope)" >&2 + } + else + gh api "repos/${owner_repo}/check-runs" \ + -F name="$check_name" \ + -F head_sha="$sha" \ + -F external_id="$external_id" \ + -F status="completed" \ + -F conclusion="$conclusion" \ + -F "output[title]=$title" \ + -F "output[summary]=$summary" > /dev/null 2>&1 || { + echo "WARN: could not create check-run (likely missing checks:write scope)" >&2 + } + fi +} + +# apply_label <pr> <label> — creates label if missing, adds to PR, removes other sdk-review labels +apply_label() { + local pr="$1" label="$2" + local owner_repo; owner_repo=$(detect_owner_repo) + + # ensure labels exist + for l in "sdk-review: ✅ passed:0e8a16" "sdk-review: ❌ blocked:b60205" \ + "sdk-review: ⚠️ flagged:e4e669" "sdk-review: skipped:cccccc"; do + local name color + name="${l%:*}"; color="${l##*:}" + gh api "repos/${owner_repo}/labels" -F name="$name" -F color="$color" > /dev/null 2>&1 || true + done + + # remove any existing sdk-review labels first + gh pr view "$pr" --json labels -q '.labels[].name' 2>/dev/null | grep '^sdk-review:' | while read -r existing; do + gh pr edit "$pr" --remove-label "$existing" > /dev/null 2>&1 || true + done + + # add the target label + gh pr edit "$pr" --add-label "$label" > /dev/null 2>&1 || true +} + +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + cmd="${1:-}"; shift || true + case "$cmd" in + check_gh_auth) check_gh_auth "$@" ;; + detect_hostname) detect_hostname "$@" ;; + detect_owner_repo) detect_owner_repo "$@" ;; + get_pr_head_sha) get_pr_head_sha "$@" ;; + delete_prior_bot_artifacts) delete_prior_bot_artifacts "$@" ;; + post_inline_comment) post_inline_comment "$@" ;; + post_summary_comment) post_summary_comment "$@" ;; + post_or_update_check_run) post_or_update_check_run "$@" ;; + apply_label) apply_label "$@" ;; + *) echo "Usage: github-api.sh <command> args" >&2; exit 2 ;; + esac +fi diff --git a/.claude/scripts/lib/json-emit.sh b/.claude/scripts/lib/json-emit.sh new file mode 100755 index 00000000..4917107d --- /dev/null +++ b/.claude/scripts/lib/json-emit.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# lib/json-emit.sh — Shared JSON output helpers for check scripts. +# Every check-<X>.sh emits a JSON object matching the schema in 00-COMMON.md §3. +set -euo pipefail + +# emit_finding writes a single finding object to stdout (one JSON line per call). +# Args: rule severity file line message [suggestion] +emit_finding() { + local rule="$1" severity="$2" file="$3" line="$4" message="$5" suggestion="${6:-}" + jq -cn \ + --arg rule "$rule" \ + --arg severity "$severity" \ + --arg file "$file" \ + --argjson line "$line" \ + --arg message "$message" \ + --arg suggestion "$suggestion" \ + '{rule:$rule, severity:$severity, file:$file, line:$line, message:$message, suggestion:$suggestion}' +} + +# emit_report assembles the full JSON report from a set of findings (JSONL on stdin). +# Args: check_name language status started_at +emit_report() { + local check="$1" language="$2" status="$3" started_at="$4" + local tmp; tmp=$(mktemp) + cat > "$tmp" + local block_count flag_count + block_count=$(jq -s '[.[] | select(.severity=="BLOCK")] | length' < "$tmp") + flag_count=$(jq -s '[.[] | select(.severity=="FLAG")] | length' < "$tmp") + # Build findings array from JSONL (jq -s reads each JSON object and produces one array) + local findings; findings=$(jq -s '.' < "$tmp") + + jq -n \ + --arg check "$check" \ + --arg version "1.0.0" \ + --arg language "$language" \ + --arg status "$status" \ + --arg started_at "$started_at" \ + --argjson block_count "$block_count" \ + --argjson flag_count "$flag_count" \ + --argjson findings "$findings" \ + '{ + check: $check, + version: $version, + language: $language, + status: $status, + started_at: $started_at, + duration_ms: 0, + modules_analysed: [], + findings: $findings, + summary: { + block_count: $block_count, + flag_count: $flag_count, + pass_criteria_met: [], + pass_criteria_failed: [] + } + }' + rm -f "$tmp" +} + +# status_from_findings derives PASS/FLAG/BLOCK from a JSONL of findings on stdin. +status_from_findings() { + local input; input=$(cat) + if [ -z "$input" ]; then echo "PASS"; return; fi + if echo "$input" | jq -s -e 'any(.severity=="BLOCK")' > /dev/null 2>&1; then + echo "BLOCK" + elif echo "$input" | jq -s -e 'any(.severity=="FLAG")' > /dev/null 2>&1; then + echo "FLAG" + else + echo "PASS" + fi +} + +# now_iso returns the current UTC time in ISO-8601 format. +now_iso() { + date -u +"%Y-%m-%dT%H:%M:%SZ" +} diff --git a/.claude/scripts/lib/predicates.sh b/.claude/scripts/lib/predicates.sh new file mode 100755 index 00000000..a4b55bd0 --- /dev/null +++ b/.claude/scripts/lib/predicates.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# lib/predicates.sh — Evaluate scope predicates for rules. +# Called by orchestrate.sh before each check runs. +# Outputs comma-separated rule IDs to SKIP because predicates are false. +set -euo pipefail + +# has_commit_type <base_sha> <head_sha> <type1,type2,...> → prints "true" or "false" +has_commit_type() { + local base="$1" head="$2" types="$3" + local pattern + pattern=$(echo "$types" | tr ',' '|') + if git log "${base}..${head}" --format=%s 2>/dev/null | grep -qE "^(${pattern})(\(.*\))?!?: "; then + echo "true" + else + echo "false" + fi +} + +# module_shape <module_dir> → prints "client" | "patch" | "config" | "other" +module_shape() { + local dir="$1" + if [ -f "$dir/client.py" ] || [ -f "$dir/_http.py" ] || compgen -G "$dir/*Client.java" > /dev/null 2>&1; then + echo "client" + elif [ -f "$dir/_patch.py" ] || compgen -G "$dir/patch*.py" > /dev/null 2>&1; then + echo "patch" + elif [ -f "$dir/config.py" ] && [ ! -f "$dir/client.py" ]; then + echo "config" + else + echo "other" + fi +} + +# reuse_toml_aggregate_present <repo_root> → prints "true" or "false" +reuse_toml_aggregate_present() { + local root="${1:-.}" + local reuse="$root/REUSE.toml" + if [ ! -f "$reuse" ]; then echo "false"; return; fi + if grep -qE 'path\s*=\s*"\*\*?"' "$reuse" 2>/dev/null; then + echo "true" + else + echo "false" + fi +} + +# file_in_added_set <file> <added_lines_file> → prints "true" or "false" +file_in_added_set() { + local file="$1" added_lines_file="$2" + if grep -q "^${file}:" "$added_lines_file" 2>/dev/null; then + echo "true" + else + echo "false" + fi +} + +# line_in_added_set <file> <line> <added_lines_file> → prints "true" or "false" +line_in_added_set() { + local file="$1" line="$2" added_lines_file="$3" + if grep -q "^${file}:${line}$" "$added_lines_file" 2>/dev/null; then + echo "true" + else + echo "false" + fi +} + +# filter_findings_by_hunk <report_file> <added_lines_file> → prints filtered report +filter_findings_by_hunk() { + local report="$1" added="$2" + jq --slurpfile added <(sort -u "$added" | jq -R .) ' + .findings |= map( + select(($added[0] // []) | index(.file + ":" + (.line|tostring)) != null) + ) + ' "$report" +} + +# When executed directly, dispatch to subcommand +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + cmd="${1:-}"; shift || true + case "$cmd" in + has_commit_type) has_commit_type "$@" ;; + module_shape) module_shape "$@" ;; + reuse_toml_aggregate_present) reuse_toml_aggregate_present "$@" ;; + file_in_added_set) file_in_added_set "$@" ;; + line_in_added_set) line_in_added_set "$@" ;; + filter_findings_by_hunk) filter_findings_by_hunk "$@" ;; + *) echo "Usage: predicates.sh <command> [args]" >&2; exit 2 ;; + esac +fi diff --git a/.claude/scripts/lib/skill-self-skip.sh b/.claude/scripts/lib/skill-self-skip.sh new file mode 100755 index 00000000..53658845 --- /dev/null +++ b/.claude/scripts/lib/skill-self-skip.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# lib/skill-self-skip.sh — return "true" if a file is part of the SDK review skill itself +# (used to prevent meta-review-loop where the skill's own files trigger findings). +set -euo pipefail + +is_skill_file() { + local file="$1" + case "$file" in + .claude/*) echo "true"; return ;; + tests/sdk-review/*) echo "true"; return ;; + .github/workflows/sdk-*) echo "true"; return ;; + docs/PR-REVIEW.md|docs/BRANCH-PROTECTION-SETUP.md) echo "true"; return ;; + *) echo "false" ;; + esac +} + +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + is_skill_file "$@" +fi diff --git a/.claude/scripts/lib/suppression.sh b/.claude/scripts/lib/suppression.sh new file mode 100755 index 00000000..1789a8ad --- /dev/null +++ b/.claude/scripts/lib/suppression.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# lib/suppression.sh — Parse # sdk-review: ignore[<check>] comments. +# Outputs "<file>:<line>:<check>" tuples of suppressions found in a diff. +set -euo pipefail + +# Line-level: any occurrence of "sdk-review: ignore[X,Y]" or "sdk-review: ignore" on same line +# File-level: "sdk-review-ignore-file: X,Y" within first 20 lines of file + +# parse_line_suppressions <file> +# Scans the file and emits <file>:<line>:<check> for each suppression on that line. +parse_line_suppressions() { + local file="$1" + [ -f "$file" ] || return 0 + awk -v file="$file" ' + match($0, /sdk-review:[[:space:]]*ignore(\[[^]]*\])?/) { + # extract [checks] if present + m = substr($0, RSTART, RLENGTH) + if (match(m, /\[[^]]*\]/)) { + checks = substr(m, RSTART+1, RLENGTH-2) + n = split(checks, arr, ",") + for (j=1; j<=n; j++) { + gsub(/^ +| +$/, "", arr[j]) + print file ":" NR ":" arr[j] + } + } else { + print file ":" NR ":*" + } + } + ' "$file" +} + +# parse_file_suppressions <file> +# Emits <file>:*:<check> when a file-level suppression header is present in first 20 lines. +parse_file_suppressions() { + local file="$1" + [ -f "$file" ] || return 0 + head -20 "$file" 2>/dev/null | awk -v file="$file" ' + match($0, /sdk-review-ignore-file:[[:space:]]*[a-zA-Z0-9_,-]+/) { + m = substr($0, RSTART, RLENGTH) + sub(/^sdk-review-ignore-file:[[:space:]]*/, "", m) + n = split(m, arr, ",") + for (j=1; j<=n; j++) { + gsub(/^ +| +$/, "", arr[j]) + print file ":*:" arr[j] + } + } + ' +} + +# is_suppressed <rule> <file> <line> <suppressions_file> +# Locked rules (SEC-*, HC-03, DIS-06, LIC-01/02, BND-02, BND-05, BREAKING-*) NEVER suppressed. +is_suppressed() { + local rule="$1" file="$2" line="$3" supp_file="$4" + + case "$rule" in + SEC-*|HC-03|DIS-06|LIC-01|LIC-02|BND-02|BND-05|BREAKING-*) + echo "false"; return + ;; + esac + + [ -f "$supp_file" ] || { echo "false"; return; } + + # exact line + rule + if grep -q "^${file}:${line}:${rule}$" "$supp_file"; then echo "true"; return; fi + # exact line + wildcard + if grep -q "^${file}:${line}:\*$" "$supp_file"; then echo "true"; return; fi + # file + rule + if grep -q "^${file}:\*:${rule}$" "$supp_file"; then echo "true"; return; fi + + echo "false" +} + +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + cmd="${1:-}"; shift || true + case "$cmd" in + parse_line) parse_line_suppressions "$@" ;; + parse_file) parse_file_suppressions "$@" ;; + is_suppressed) is_suppressed "$@" ;; + *) echo "Usage: suppression.sh {parse_line|parse_file|is_suppressed} args" >&2; exit 2 ;; + esac +fi diff --git a/.claude/scripts/lib/tier-manager.sh b/.claude/scripts/lib/tier-manager.sh new file mode 100755 index 00000000..113278db --- /dev/null +++ b/.claude/scripts/lib/tier-manager.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# lib/tier-manager.sh — apply rule tiers (SHADOW/FLAG/BLOCK/BLOCK_LOCKED) to a report. +# Reads rules.yaml, filters/reclassifies findings based on rule tier. +set -euo pipefail + +# get_tier <rule> <rules_yaml_file> → prints tier +get_tier() { + local rule="$1" cfg="$2" + # Grep for tier under rule id (simple YAML parsing — no yq dep required) + # Format expected: + # <RULE-ID>: + # tier: SHADOW|FLAG|BLOCK|BLOCK_LOCKED + awk -v rule="$rule" ' + $0 ~ ("^ " rule ":$") { in_rule=1; next } + in_rule && /^ [A-Z]/ { in_rule=0 } + in_rule && /tier:/ { + gsub(/^[[:space:]]*tier:[[:space:]]*/, "") + print + exit + } + ' "$cfg" 2>/dev/null || echo "" +} + +# apply_tiers_to_report <report> <rules_yaml> → prints filtered report +# SHADOW findings are stripped from .findings and moved to .shadow_findings +# BLOCK_LOCKED findings get a locked=true flag +apply_tiers_to_report() { + local report="$1" cfg="$2" + + local kept=() shadow=() + local n; n=$(jq '.findings | length' "$report") + local i=0 + while [ "$i" -lt "$n" ]; do + local f rule tier severity + f=$(jq -c ".findings[$i]" "$report") + rule=$(echo "$f" | jq -r '.rule') + tier=$(get_tier "$rule" "$cfg") + [ -z "$tier" ] && tier=$(echo "$f" | jq -r '.severity') # fall back to severity + severity=$(echo "$f" | jq -r '.severity') + + case "$tier" in + SHADOW) + shadow+=("$(echo "$f" | jq -c '. + {tier: "SHADOW"}')") + ;; + FLAG) + kept+=("$(echo "$f" | jq -c '.severity = "FLAG" | .tier = "FLAG"')") + ;; + BLOCK) + kept+=("$(echo "$f" | jq -c '.severity = "BLOCK" | .tier = "BLOCK"')") + ;; + BLOCK_LOCKED) + kept+=("$(echo "$f" | jq -c '.severity = "BLOCK" | .tier = "BLOCK_LOCKED" | .locked = true')") + ;; + *) + kept+=("$(echo "$f" | jq -c ". + {tier: \"$severity\"}")") + ;; + esac + i=$((i + 1)) + done + + local kept_json shadow_json + kept_json=$(printf '%s\n' "${kept[@]:-}" | jq -s -c '.') + shadow_json=$(printf '%s\n' "${shadow[@]:-}" | jq -s -c '.') + + jq --argjson kept "$kept_json" --argjson shadow "$shadow_json" \ + '.findings = $kept | .shadow_findings = $shadow' "$report" +} + +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + cmd="${1:-}"; shift || true + case "$cmd" in + get_tier) get_tier "$@" ;; + apply_tiers_to_report) apply_tiers_to_report "$@" ;; + *) echo "Usage: tier-manager.sh {get_tier|apply_tiers_to_report} args" >&2; exit 2 ;; + esac +fi diff --git a/.claude/scripts/orchestrate.sh b/.claude/scripts/orchestrate.sh new file mode 100755 index 00000000..55432fae --- /dev/null +++ b/.claude/scripts/orchestrate.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# orchestrate.sh — main entry point. Runs all 20 checks and posts results. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB="$SCRIPT_DIR/lib" + +# shellcheck source=lib/json-emit.sh +source "$LIB/json-emit.sh" +# shellcheck source=lib/github-api.sh +source "$LIB/github-api.sh" + +PR_NUMBER="${1:-}" +DRY_RUN="${DRY_RUN:-false}" +if [ "${2:-}" = "--dry-run" ]; then DRY_RUN=true; fi + +if [ -z "$PR_NUMBER" ]; then + echo "Usage: orchestrate.sh <PR_NUMBER> [--dry-run]" >&2 + exit 2 +fi + +REPO_ROOT="${REPO_ROOT:-$(pwd)}" +export REPO_ROOT +CONFIG_DIR="$REPO_ROOT/.claude/config" +export CONFIG_DIR +if [ -n "${TMPDIR_RUN:-}" ]; then + mkdir -p "$TMPDIR_RUN" +else + TMPDIR_RUN="$(mktemp -d)" +fi +trap '[ -n "${KEEP_TMP:-}" ] || rm -rf "$TMPDIR_RUN"' EXIT + +echo "▶ SDK Module Review — PR #$PR_NUMBER (dry-run=$DRY_RUN)" +echo " Working dir: $TMPDIR_RUN" + +# 1. Preflight — detect language + hostname + fetch PR data +LANGUAGE=$("$LIB/detect-language.sh" "$REPO_ROOT") +export LANGUAGE +echo " Language: $LANGUAGE" + +if [ "$DRY_RUN" != "true" ]; then + HOSTNAME=$(detect_hostname) + check_gh_auth "$HOSTNAME" +fi + +# 2. Fetch diff + PR metadata +if [ -f "${DIFF_FILE:-}" ]; then + cp "$DIFF_FILE" "$TMPDIR_RUN/pr.diff" +elif [ "$DRY_RUN" = "true" ] && [ -n "${LOCAL_DIFF:-}" ]; then + cp "$LOCAL_DIFF" "$TMPDIR_RUN/pr.diff" +else + gh pr diff "$PR_NUMBER" > "$TMPDIR_RUN/pr.diff" +fi + +if [ "$DRY_RUN" != "true" ]; then + gh pr view "$PR_NUMBER" --json body -q .body > "$TMPDIR_RUN/pr-body.txt" 2>/dev/null || echo "" > "$TMPDIR_RUN/pr-body.txt" + HEAD_SHA=$(get_pr_head_sha "$PR_NUMBER") +elif [ -n "${LOCAL_PR_BODY:-}" ] && [ -f "$LOCAL_PR_BODY" ]; then + cp "$LOCAL_PR_BODY" "$TMPDIR_RUN/pr-body.txt" + HEAD_SHA="${HEAD_SHA:-HEAD}" +else + echo "" > "$TMPDIR_RUN/pr-body.txt" + HEAD_SHA="${HEAD_SHA:-HEAD}" +fi + +export DIFF_FILE="$TMPDIR_RUN/pr.diff" +export PR_BODY_FILE="$TMPDIR_RUN/pr-body.txt" +export HEAD_SHA +export BASE_SHA="${BASE_SHA:-$(git merge-base origin/main HEAD 2>/dev/null || echo HEAD~10)}" +export BREAKING_JSON="$TMPDIR_RUN/breaking.json" + +# 3. Compute added-lines set (used for hunk attribution) +"$LIB/diff-added-lines.sh" < "$DIFF_FILE" > "$TMPDIR_RUN/added-lines.txt" +export ADDED_LINES_FILE="$TMPDIR_RUN/added-lines.txt" + +# 4. Run breaking-change detector +python3 "$LIB/breaking-detector.py" "$BASE_SHA" "$HEAD_SHA" > "$BREAKING_JSON" 2>/dev/null || echo '{"breaking_detected":false,"kinds":[],"details":[]}' > "$BREAKING_JSON" + +# 5. Detect disclosure profile from remote +if [ "$DRY_RUN" != "true" ]; then + remote_url=$(git remote get-url origin 2>/dev/null || echo "") + if [[ "$remote_url" == *"github.tools.sap"* ]]; then + export DISCLOSURE_PROFILE="internal" + else + export DISCLOSURE_PROFILE="public" + fi +else + export DISCLOSURE_PROFILE="${DISCLOSURE_PROFILE:-public}" +fi + +# 6. Run all 20 checks in parallel +checks=(secrets license-spdx disclosure hardcode telemetry + docs bdd patterns versioning commits + errors-logging testing-depth http-hygiene concurrency + deps-supply deletion-hygiene constants binding-shape + quality-gate-parity pr-size) + +for check in "${checks[@]}"; do + script="$SCRIPT_DIR/check-${check}.sh" + if [ ! -x "$script" ]; then continue; fi + DIFF_FILE="$DIFF_FILE" PR_BODY_FILE="$PR_BODY_FILE" \ + "$script" < "$DIFF_FILE" > "$TMPDIR_RUN/report-${check}.json" 2> "$TMPDIR_RUN/${check}.err" & +done +wait + +# 6.5. Collect suppression tuples from files touched by the diff, then filter each report +touched_files=$(grep -oE '^\+\+\+ b/[^[:space:]]+' "$DIFF_FILE" 2>/dev/null | sed 's|^+++ b/||' | sort -u || true) +supp_file="$TMPDIR_RUN/suppressions.txt" +: > "$supp_file" +if [ -n "$touched_files" ]; then + while IFS= read -r f; do + [ -z "$f" ] && continue + [ -f "$REPO_ROOT/$f" ] || continue + bash "$LIB/suppression.sh" parse_line "$REPO_ROOT/$f" 2>/dev/null | sed "s|^$REPO_ROOT/|| ; s|^\./||" >> "$supp_file" || true + bash "$LIB/suppression.sh" parse_file "$REPO_ROOT/$f" 2>/dev/null | sed "s|^$REPO_ROOT/|| ; s|^\./||" >> "$supp_file" || true + done <<< "$touched_files" +fi + +for check in "${checks[@]}"; do + report="$TMPDIR_RUN/report-${check}.json" + [ -f "$report" ] || continue + filtered=$(bash "$LIB/apply-suppression.sh" apply "$report" "$supp_file" 2>/dev/null || cat "$report") + echo "$filtered" > "$report" +done + +# 7. Aggregate reports (applies tier gating per rules.yaml) +RULES_YAML="$REPO_ROOT/.claude/config/rules.yaml" \ + "$SCRIPT_DIR/aggregate.sh" "$TMPDIR_RUN" > "$TMPDIR_RUN/summary.json" + +# 8. Post signals (unless dry-run) +if [ "$DRY_RUN" = "true" ]; then + echo "" + echo "▶ DRY-RUN summary:" + jq -r ' + " BLOCK: \(.summary.block_count) FLAG: \(.summary.flag_count) SHADOW: \(.summary.shadow_count)", + "", + "Findings:", + (.findings[] | " [\(.severity)] \(.rule) at \(.file):\(.line) — \(.message)") + ' "$TMPDIR_RUN/summary.json" + echo "" + echo "▶ Full report at: $TMPDIR_RUN/summary.json" + exit "$(jq -r '.summary.block_count | if . > 0 then 1 else 0 end' "$TMPDIR_RUN/summary.json")" +fi + +# 9. Idempotency: delete prior bot artifacts +delete_prior_bot_artifacts "$PR_NUMBER" + +# 10. Post inline comments +n_inline=$(jq '.findings | length' "$TMPDIR_RUN/summary.json") +i=0 +while [ "$i" -lt "$n_inline" ]; do + f=$(jq -c ".findings[$i]" "$TMPDIR_RUN/summary.json") + file=$(echo "$f" | jq -r '.file') + line=$(echo "$f" | jq -r '.line') + rule=$(echo "$f" | jq -r '.rule') + sev=$(echo "$f" | jq -r '.severity') + msg=$(echo "$f" | jq -r '.message') + body="<!-- sdk-review:v1 check=$rule id=$rule-$i --> +**[$sev] $rule** + +$msg" + post_inline_comment "$PR_NUMBER" "$HEAD_SHA" "$file" "$line" "$body" || true + i=$((i + 1)) +done + +# 11. Post summary comment +summary_body=$(jq -r ' + "<!-- sdk-review:v1 kind=summary --> +## SDK Module Review + +| Check | Status | Findings | +|-------|--------|----------| +" + (.per_check_summary | to_entries | map("| \(.key) | \(.value.status) | \(.value.count) |") | join("\n")) + " + +<details><summary>Details</summary> + +" + (.findings | map("- **[\(.severity)] \(.rule)** at `\(.file):\(.line)` — \(.message)") | join("\n")) + " + +</details> + +--- +_Generated by sdk-review-skill · v1_" +' "$TMPDIR_RUN/summary.json") + +post_summary_comment "$PR_NUMBER" "$summary_body" + +# 12. Post check-run +conclusion=$(jq -r 'if .summary.block_count > 0 then "failure" elif .summary.flag_count > 0 then "neutral" else "success" end' "$TMPDIR_RUN/summary.json") +title=$(jq -r "\"SDK Review: \(.summary.block_count) BLOCK, \(.summary.flag_count) FLAG\"" "$TMPDIR_RUN/summary.json") +post_or_update_check_run "$HEAD_SHA" "$conclusion" "$title" "$summary_body" + +# 13. Apply label +case "$conclusion" in + success) label="sdk-review: ✅ passed" ;; + neutral) label="sdk-review: ⚠️ flagged" ;; + failure) label="sdk-review: ❌ blocked" ;; +esac +apply_label "$PR_NUMBER" "$label" + +# 14. Exit +exit "$(jq -r '.summary.block_count | if . > 0 then 1 else 0 end' "$TMPDIR_RUN/summary.json")" diff --git a/.github/workflows/sdk-module-review.yml b/.github/workflows/sdk-module-review.yml new file mode 100644 index 00000000..ed45abe3 --- /dev/null +++ b/.github/workflows/sdk-module-review.yml @@ -0,0 +1,42 @@ +name: SDK Module Review +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: + inputs: + pr_number: + description: "PR number to review" + required: true + +jobs: + review: + runs-on: ubuntu-latest + if: github.event.pull_request.draft == false || github.event_name == 'workflow_dispatch' + permissions: + contents: read + pull-requests: write + checks: write + issues: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install jq + run: sudo apt-get install -y jq + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Checkout sibling SDK repo (BDD parity) + uses: actions/checkout@v4 + with: + repository: ${{ vars.SIBLING_SDK_REPO }} + path: .sibling-sdk + token: ${{ secrets.SIBLING_SDK_TOKEN }} + continue-on-error: true + - name: Run SDK review + env: + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + SDK_SIBLING_PATH: ${{ github.workspace }}/.sibling-sdk + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash .claude/scripts/orchestrate.sh "$PR_NUMBER" diff --git a/.github/workflows/sdk-skill-isolation-check.yml b/.github/workflows/sdk-skill-isolation-check.yml new file mode 100644 index 00000000..88a7fa16 --- /dev/null +++ b/.github/workflows/sdk-skill-isolation-check.yml @@ -0,0 +1,59 @@ +name: SDK Skill Isolation Check +# Validates that sub-PRs into feat/sdk-review-skill satisfy the isolation criteria +# from 06-INCREMENTAL-DELIVERY.md §isolation criteria. +on: + pull_request: + branches: [feat/sdk-review-skill] + +jobs: + isolation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install tools + run: | + sudo apt-get update && sudo apt-get install -y jq shellcheck bats + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install ruff + run: pip install ruff + - name: 1. Compiles alone — shellcheck + run: | + shellcheck -x --severity=error .claude/scripts/**/*.sh + - name: 1. Compiles alone — ruff + run: | + ruff check .claude/scripts/lib/*.py + - name: 2. Testable alone — bats + run: | + bats tests/sdk-review/*.bats + - name: 3. Revert-safe — nothing in main branch broken + run: | + # Verify that scripts still function after this PR is applied + bash .claude/scripts/lib/detect-language.sh /tmp || echo "detect-language exit acceptable" + bash .claude/scripts/lib/diff-added-lines.sh < /dev/null || echo "diff-added-lines exit acceptable" + - name: 4. Sub-PR size check + run: | + # Warn if PR exceeds max sub-PR size (400 LOC per 06-INCREMENTAL-DELIVERY.md) + adds=$(git diff origin/feat/sdk-review-skill...HEAD --shortstat | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo 0) + if [ "${adds:-0}" -gt 400 ]; then + echo "::warning::Sub-PR has $adds additions (>400 target). Consider splitting per 06-INCREMENTAL-DELIVERY.md." + fi + - name: 5. Fixture-tested check + run: | + # Every new check-*.sh must have at least one fixture + new_checks=$(git diff origin/feat/sdk-review-skill...HEAD --name-only --diff-filter=A | grep '^\.claude/scripts/check-' || true) + missing_fixtures="" + for check in $new_checks; do + name=$(basename "$check" .sh | sed 's/^check-//') + if ! ls tests/sdk-review/fixtures/${name}-*.diff >/dev/null 2>&1; then + missing_fixtures="$missing_fixtures $name" + fi + done + if [ -n "$missing_fixtures" ]; then + echo "::error::New checks missing fixtures:$missing_fixtures" + exit 1 + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef9b4c23..bfbe8788 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,31 @@ Before starting your contribution: - **Read [Code Guidelines](docs/GUIDELINES.md)** to understand our development standards and conventions - **Review existing user guides** to understand the expected documentation format and API patterns +#### AI-assisted contribution skills — required for review + +Before maintainers review your PR, you **must** run the SDK Module Review skill and ensure it passes. Reviewers will not review PRs where `sdk-module-review` is failing or missing. + +**How to run:** + +- **In CI (automatic):** every PR triggers the `sdk-module-review` GitHub Action. It runs 20 deterministic checks and posts findings (inline comments + summary + check-run + label). This is a **required status check** — the merge button is disabled until the run is green. + +- **Locally (recommended, before pushing):** + ``` + /review-new-module <PR_NUMBER> # posts findings to the PR + /review-new-module <PR_NUMBER> --dry-run # analysis only, prints locally + ``` + Uses the exact same scripts the CI uses. No LLM calls — same input, same output. + +**Related skills:** + +- **`scaffold-module`** (`.claude/skills/scaffold-module/`) — bootstraps a new BTP service module with the standard directory layout, telemetry wiring, and stubs. Use before writing code for a new module: + ``` + /scaffold-module <module-name> + ``` +- **`review-pr`** (`.claude/skills/review-pr/`) — same underlying pipeline as `/review-new-module`; useful when reviewing someone else's PR interactively. + +See [`docs/PR-REVIEW.md`](docs/PR-REVIEW.md) for the full rule catalog, suppression syntax, and how to interact with review findings. + ### 2. Create or claim an issue 1. Go to our repository's [GitHub Issues page](https://github.com/SAP/cloud-sdk-python/issues) diff --git a/dist/sap_cloud_sdk-0.4.0-py3-none-any.whl b/dist/sap_cloud_sdk-0.4.0-py3-none-any.whl new file mode 100644 index 00000000..434290ad Binary files /dev/null and b/dist/sap_cloud_sdk-0.4.0-py3-none-any.whl differ diff --git a/docs/BRANCH-PROTECTION-SETUP.md b/docs/BRANCH-PROTECTION-SETUP.md new file mode 100644 index 00000000..ceec9ce2 --- /dev/null +++ b/docs/BRANCH-PROTECTION-SETUP.md @@ -0,0 +1,177 @@ +# Branch Protection Setup — sdk-module-review as required check + +This guide is for **repo admins**. It documents how to configure the +`sdk-module-review` action as a required status check so that PRs cannot be +merged until the review passes. + +Once configured, every PR against `main` will be blocked from merge until: +1. The `sdk-module-review` action runs +2. The action reports success (no `BLOCK` findings) +3. All other required checks (existing CI, REUSE, etc.) also pass + +--- + +## Prerequisites + +- You have **admin** permission on the repo +- The `feat/sdk-review-skill` PR has been merged to `main`, so + `.github/workflows/sdk-module-review.yml` is live +- At least one PR has run the workflow successfully (so GitHub knows the + check-name `sdk-module-review` exists — required checks can only be added + after they've fired at least once) + +## Steps (GitHub UI) + +1. Navigate to **Settings → Branches → Branch protection rules** +2. If a rule already exists for `main`, edit it. Otherwise click **Add rule** + and enter `main` as the branch name pattern. +3. Under **Protect matching branches**, enable: + - ☑ **Require a pull request before merging** + - ☑ Require approvals: `1` (or more per team convention) + - ☑ Dismiss stale pull request approvals when new commits are pushed + - ☑ Require review from Code Owners + - ☑ **Require status checks to pass before merging** + - ☑ Require branches to be up to date before merging + - In the search box, add: + - `sdk-module-review` ← our skill + - `test` (or whatever the existing CI is called) + - `reuse` (if REUSE-check is used) + - ☑ **Require conversation resolution before merging** + - ☑ **Do not allow bypassing the above settings** (admins included) +4. Click **Create** or **Save changes** + +## Steps (via gh CLI, alternative) + +```bash +# cloud-sdk-python (public GitHub) +gh api -X PUT "repos/SAP/cloud-sdk-python/branches/main/protection" \ + --input - <<'EOF' +{ + "required_status_checks": { + "strict": true, + "contexts": ["sdk-module-review", "test", "reuse"] + }, + "enforce_admins": true, + "required_pull_request_reviews": { + "dismiss_stale_reviews": true, + "require_code_owner_reviews": true, + "required_approving_review_count": 1 + }, + "restrictions": null, + "required_conversation_resolution": true +} +EOF + +# cloud-sdk-java (internal GHES) +gh api --hostname github.tools.sap -X PUT \ + "repos/application-foundation/cloud-sdk-java/branches/main/protection" \ + --input - <<'EOF' +{ + "required_status_checks": { + "strict": true, + "contexts": ["sdk-module-review", "ci", "codeql-sast-analysis"] + }, + "enforce_admins": true, + "required_pull_request_reviews": { + "dismiss_stale_reviews": true, + "require_code_owner_reviews": true, + "required_approving_review_count": 1 + }, + "restrictions": null +} +EOF +``` + +Adjust the `contexts` list to include the CI check-names actually used by the +repo (see the "Checks" tab of any recent PR to confirm the exact names). + +## Verify + +After configuration, open a test PR (or use any open PR): + +```bash +gh pr view <PR_NUMBER> --json statusCheckRollup -q '.statusCheckRollup[].name' +``` + +You should see `sdk-module-review` in the list. If the check hasn't run yet +(never fired on this branch), it will appear as "expected" (pending). + +## Rollout stages + +We recommend a **SHADOW → FLAG → BLOCK** progression to minimise contributor +friction: + +### Stage 1 — SHADOW (weeks 1–2) + +- The workflow runs on every PR but **rules are downgraded to `SHADOW`** +- Findings are logged to `.claude/telemetry/*.jsonl` but **not posted to the PR** +- Contributors are unaffected; maintainers observe FP rate + +To enable SHADOW mode, edit `.claude/config/rules.yaml`: +```yaml +rules: + # temporarily downgrade all BLOCK to SHADOW for first two weeks + BND-02: { tier: SHADOW } + BREAKING-01: { tier: SHADOW } + # ... etc +``` + +Or set an env var in the workflow: `SDK_REVIEW_TIER_OVERRIDE=shadow-all`. + +### Stage 2 — FLAG (weeks 3–4) + +- Rules promoted to `FLAG` — findings posted as inline comments and summary, + but check-run is **not** required for merge +- Contributors see the review and can act on it +- Maintainers verify FP rate stays < 5 % on real PRs + +### Stage 3 — BLOCK (week 5+) + +- `sdk-module-review` added as required status check (this document's main topic) +- Merges blocked on `BLOCK` findings +- `BLOCK_LOCKED` rules (secrets, SPDX in public, token URL concat, breaking + changes without declaration) are non-suppressible + +## Rollback + +If `sdk-module-review` fires too aggressively and needs to be turned off: + +1. Remove `sdk-module-review` from the required-checks list (via UI or `gh api`) +2. Or disable the workflow entirely: `.github/workflows/sdk-module-review.yml` → + set `on: workflow_dispatch` only (no auto-triggers) +3. Individual noisy rules can be downgraded in `.claude/config/rules.yaml`: + ```yaml + rules: + RULE-ID: { tier: FLAG } # was BLOCK; downgrade to advisory + RULE-ID: { tier: SHADOW } # or silence entirely + ``` + +`BLOCK_LOCKED` rules cannot be downgraded via config — they are safety-critical +(secrets, license, disclosure in public repo, BTP token URL concat). To +temporarily disable one, remove it from `rules.yaml` altogether and open a +follow-up issue to reintroduce it. + +## Troubleshooting + +- **Check-run not appearing on new PRs**: verify the workflow file + `.github/workflows/sdk-module-review.yml` is present on `main` and the + action has permissions `contents: read`, `pull-requests: write`, + `checks: write`, `issues: write` (for labels) +- **Check-run runs but never completes**: check the workflow logs for auth + errors (`gh auth status`) or missing `SIBLING_SDK_TOKEN` secret (cross-repo + BDD parity is optional — degrades to `FLAG` if unavailable) +- **False positives**: see `docs/PR-REVIEW.md § Suppressing false positives` + and `§ Tuning noisy rules` +- **Required check missing from branch protection dropdown**: the check must + have fired at least once on any branch before GitHub lists it. Open a + throwaway PR to trigger it, or dispatch the workflow manually: + ```bash + gh workflow run sdk-module-review.yml -f pr_number=<N> + ``` + +## Cross-references + +- [`docs/PR-REVIEW.md`](./PR-REVIEW.md) — user-facing docs on what the skill checks +- [`.claude/config/rules.yaml`](../.claude/config/rules.yaml) — rule catalog with tiers +- [`.claude/config/baseline.json`](../.claude/config/baseline.json) — repo-specific exemptions +- [`.github/workflows/sdk-module-review.yml`](../.github/workflows/sdk-module-review.yml) — the workflow diff --git a/docs/PR-REVIEW.md b/docs/PR-REVIEW.md new file mode 100644 index 00000000..e42dfe32 --- /dev/null +++ b/docs/PR-REVIEW.md @@ -0,0 +1,245 @@ +# SDK Module Review + +Every PR to this repo is reviewed automatically by the **SDK Module Review** skill. +This document explains what it checks, how to interact with findings, and how to tune noisy rules. + +> **Required before review.** The `sdk-module-review` check-run must be green **before a maintainer will review the PR**. It is a required status check in branch protection — merges are blocked until it passes. Reviewers will not spend time on PRs where the automated review is failing. + +--- + +## How it runs + +**Automatic (GitHub Action):** fires on every PR event (`opened`, `synchronize`, `reopened`, `ready_for_review`). No action needed from the contributor — the review appears on the PR within a minute or two. + +**Manual (maintainer, via CLI):** +```bash +gh workflow run sdk-module-review.yml -f pr_number=<N> +``` + +**Local (dev iteration, requires Claude Code):** +``` +/review-new-module <PR_NUMBER> # full review, posts to PR +/review-new-module <PR_NUMBER> --dry-run # analysis only, prints locally +``` + +The skill is **100% deterministic** — no LLM calls in CI. Every run on the same +commit produces the same findings. + +--- + +## Signals posted to your PR + +Every run posts up to four signals: + +1. **Inline comments** — one per finding, anchored to `file:line` in the diff +2. **Summary comment** — aggregated table of all check results in an issue comment +3. **Check-run** — appears in the "Checks" tab (green ✅ or red ❌) +4. **PR label** — `sdk-review: ✅ passed` / `❌ blocked` / `⚠️ flagged` / `skipped` + +On re-run (push more commits, dispatch workflow again), all four artifacts are +**replaced** — you won't see duplicate comments accumulating. + +--- + +## Severities + +- **BLOCK** — must fix before merge. Check-run fails; branch protection refuses the merge. +- **FLAG** — should fix; does not block merge. Check-run passes with warning. +- **PASS** — no findings for this check. +- **SHADOW** — internal telemetry; not posted, used to evaluate new rules before promoting them. + +Some rules are **locked** (marked `BLOCK_LOCKED` in `.claude/config/rules.yaml`). +Locked rules cannot be suppressed via inline comments or downgraded via config — +they are safety-critical (secrets, license, SAP-internal URL leaks, breaking-change +declarations). + +--- + +## What the skill checks + +20 checks · ~150 rules. Configured in [`.claude/config/rules.yaml`](../.claude/config/rules.yaml). + +| Check | Purpose | +|-------|---------| +| **secrets** | AWS keys, JWT, GitHub PATs, private keys, plaintext credentials | +| **license-spdx** | SPDX headers on new source files (respects `REUSE.toml`) | +| **disclosure** | No SAP-internal URLs, ORD IDs, internal Jira in public artifacts | +| **hardcode** | No hardcoded URLs, credentials, magic timeouts | +| **telemetry** | `@record_metrics` decorator on public client methods + emission tests | +| **docs** | `user-guide.md` completeness including BTP dep + regional availability | +| **bdd** | Feature files exist + cross-language parity | +| **patterns** | Factory pattern, exception hierarchy, type hints, `py.typed` | +| **versioning** | SemVer bump matches diff scope; BREAKING family fires on API changes | +| **commits** | Conventional Commits | +| **errors-logging** | `raise X from e` chaining, no sensitive info in exception messages | +| **testing-depth** | Bug fixes have tests; new modules have integration tests | +| **http-hygiene** | Session reuse, configurable timeouts | +| **concurrency** | `asyncio.Queue` dedup, thread safety on shared state | +| **deps-supply** | Dep justification, lockfile drift, no internal artifactory | +| **deletion-hygiene** | Removed symbols have zero residual references | +| **constants** | Magic values → constants/enums | +| **binding-shape** | BTP binding parsing (no `url + "/oauth/token"` concat) | +| **quality-gate-parity** | CI runs the full dev gate (ruff + format + typecheck) | +| **pr-size** | Advisory on large PRs (recommends stacked-PR workflow) | + +--- + +## Documenting BTP dependencies (DC-11..DC-16) + +If your module imports `destination`, `Fragment*`, `Certificate*`, or has +region-specific constants, `user-guide.md` **must** contain the corresponding +section. The skill fires **BLOCK** if these are missing: + +- **DC-11** — `destination` import → `## Dependencies` section mentioning "Destination Service" +- **DC-12** — Fragment usage → same section + `create_fragment_client` example +- **DC-13** — Certificate usage → same section + `create_certificate_client` example +- **DC-14** — Region constants → `## Regional Availability` section listing supported/unsupported regions +- **DC-15** — Reads `VCAP_SERVICES` → `## Configuration` section with sample binding JSON +- **DC-16** — Cross-module SDK dep → note in `## Dependencies` + +### Example templates + +**DC-11 Destination Service required:** +```markdown +## Dependencies + +This module requires **SAP BTP Destination Service**: +- Service instance name: `default` (configurable via `create_client(instance="...")`) +- Required binding: `xsuaa` credentials + `destination` service credentials +- Mount path: `/etc/secrets/sapbtp/destination/<instance>/` +- Local dev: set `CLOUD_SDK_LOCALDEV_DESTINATION=true` to use mock backing +``` + +**DC-14 Regional Availability:** +```markdown +## Regional Availability + +Available in the following BTP regions: +- ✅ `eu10` (Frankfurt) +- ✅ `us10` (Ashburn) +- ✅ `ap11` (Singapore) +- ❌ `cn40` (Shanghai) — not supported due to <reason> +``` + +--- + +## Breaking changes (BREAKING-01..04) + +If your diff contains a **breaking change** (public API removal, method signature +change, dataclass field deletion, enum value removal, exception hierarchy change), +the skill requires: + +1. Commit message uses `feat!:` or `fix!:` prefix +2. PR body has a `## Breaking Changes` section with **non-empty** content +3. PR body ticks the "Breaking change" checkbox +4. `pyproject.toml` / `pom.xml` version is bumped MINOR (or MAJOR if pre-1.0) +5. Migration path documented in PR body or `RELEASE.md` + +All four must be true — half-declared breakages are BLOCKed. This is enforced by +`BREAKING-01` and `BREAKING-02`, both `BLOCK_LOCKED` (cannot be suppressed). + +--- + +## Incremental delivery for large contributions + +For features exceeding ~500 lines or touching multiple modules, prefer **stacked PRs**: + +1. Open a feature branch off `main`: `feat/<capability>` +2. Send small, isolated PRs (≤400 lines each) targeting **your feature branch** +3. Each sub-PR passes CI standalone +4. When feature is complete, open a final PR from your feature branch to `main` + +The skill flags `PR-SIZE-01..05` (currently SHADOW tier — logged only) when a PR +exceeds thresholds. See `CONTRIBUTING.md § Incremental Delivery`. + +--- + +## Suppressing false positives + +If a rule fires incorrectly on a specific line, add a comment: + +```python +# Python +timeout = 30 # sdk-review: ignore[hardcode] +``` + +```java +// Java +final int timeout = 30; // sdk-review: ignore[hardcode] +``` + +Or for an entire file (first 20 lines): +```python +# sdk-review-ignore-file: hardcode,patterns +``` + +**You cannot suppress:** +- Any `SEC-*` rule (secrets) +- `HC-03` (SAP-internal URL leak) +- `DIS-06` (internal artifactory `--index-url`) +- `LIC-01/02` (SPDX headers) +- `BND-02` (BTP token URL concat) +- `BND-05` (binding logs credentials) +- `BREAKING-*` family + +These are locked. Fix the finding or open a discussion with maintainers. + +--- + +## When cross-language BDD parity can't be verified + +The skill checks that new modules have BDD feature files in **both** SDKs (Python +and Java). If the sibling repo can't be reached (SSO required, network unavailable, +missing checkout), `check-bdd.sh` degrades to a FLAG "cross-language parity not +verified" instead of blocking. + +The alias map at `.claude/config/module-aliases.yaml` handles name divergences +(e.g., Python `dms` ↔ Java `documentmanagement`). + +--- + +## Re-running + +Just push a new commit — the Action reruns automatically and replaces prior +review artifacts. Or dispatch manually: + +```bash +gh workflow run sdk-module-review.yml -f pr_number=<N> +``` + +--- + +## Tuning noisy rules + +Maintainers can adjust rule severity or disable rules in +`.claude/config/rules.yaml`: + +```yaml +rules: + HC-04: + tier: OFF # disable + # or + HC-04: + tier: FLAG # downgrade from BLOCK to FLAG +``` + +Locked rules ignore these overrides. + +--- + +## What if something breaks? + +- **False positive that survived suppression** → open an issue with label `sdk-review-tuning` +- **Skill errors on your PR** → open an issue with label `sdk-review-bug` +- **Suggested new rule** → open an issue with label `sdk-review-enhancement` + +--- + +## Reference + +- Rule catalog: [`.claude/config/rules.yaml`](../.claude/config/rules.yaml) +- Module aliases: [`.claude/config/module-aliases.yaml`](../.claude/config/module-aliases.yaml) +- Baseline exemptions: [`.claude/config/baseline.json`](../.claude/config/baseline.json) +- Check scripts: [`.claude/scripts/check-*.sh`](../.claude/scripts/) +- Orchestrator: [`.claude/scripts/orchestrate.sh`](../.claude/scripts/orchestrate.sh) +- Workflow: [`.github/workflows/sdk-module-review.yml`](../.github/workflows/sdk-module-review.yml) diff --git a/tests/sdk-review/fixtures/binding-token-concat.diff b/tests/sdk-review/fixtures/binding-token-concat.diff new file mode 100644 index 00000000..1b51251d --- /dev/null +++ b/tests/sdk-review/fixtures/binding-token-concat.diff @@ -0,0 +1,7 @@ +diff --git a/src/config.java b/src/config.java +index 111..222 100644 +--- a/src/config.java ++++ b/src/config.java +@@ -1,3 +1,4 @@ ++ String tokenUrl = uaaData.url + "/oauth/token"; + x = 1 diff --git a/tests/sdk-review/fixtures/clean.diff b/tests/sdk-review/fixtures/clean.diff new file mode 100644 index 00000000..ca1ecd85 --- /dev/null +++ b/tests/sdk-review/fixtures/clean.diff @@ -0,0 +1,9 @@ +diff --git a/src/module.py b/src/module.py +index 111..222 100644 +--- a/src/module.py ++++ b/src/module.py +@@ -1,3 +1,4 @@ + # existing code ++def new_helper() -> None: ++ return None + x = 1 diff --git a/tests/sdk-review/fixtures/disclosure-internal.diff b/tests/sdk-review/fixtures/disclosure-internal.diff new file mode 100644 index 00000000..be1b3cf2 --- /dev/null +++ b/tests/sdk-review/fixtures/disclosure-internal.diff @@ -0,0 +1,7 @@ +diff --git a/src/config.py b/src/config.py +index 111..222 100644 +--- a/src/config.py ++++ b/src/config.py +@@ -1,3 +1,4 @@ ++API = "https://internal.tools.sap/api" + x = 1 diff --git a/tests/sdk-review/fixtures/hardcode-url.diff b/tests/sdk-review/fixtures/hardcode-url.diff new file mode 100644 index 00000000..70c1668c --- /dev/null +++ b/tests/sdk-review/fixtures/hardcode-url.diff @@ -0,0 +1,8 @@ +diff --git a/src/sap_cloud_sdk/mymodule/client.py b/src/sap_cloud_sdk/mymodule/client.py +index 111..222 100644 +--- a/src/sap_cloud_sdk/mymodule/client.py ++++ b/src/sap_cloud_sdk/mymodule/client.py +@@ -1,3 +1,4 @@ + # existing ++API_URL = "https://api.example-real-service.com/v1" + x = 1 diff --git a/tests/sdk-review/fixtures/secrets-aws-key.diff b/tests/sdk-review/fixtures/secrets-aws-key.diff new file mode 100644 index 00000000..1c8c11a1 --- /dev/null +++ b/tests/sdk-review/fixtures/secrets-aws-key.diff @@ -0,0 +1,9 @@ +diff --git a/src/config.py b/src/config.py +new file mode 100644 +index 0000000..abc1234 +--- /dev/null ++++ b/src/config.py +@@ -0,0 +1,3 @@ ++# SPDX-License-Identifier: Apache-2.0 ++AWS_KEY = "AKIATESTFIXTUREXXXXX" ++other = "safe string" diff --git a/tests/sdk-review/fixtures/secrets-jwt.diff b/tests/sdk-review/fixtures/secrets-jwt.diff new file mode 100644 index 00000000..f4dd9804 --- /dev/null +++ b/tests/sdk-review/fixtures/secrets-jwt.diff @@ -0,0 +1,8 @@ +diff --git a/src/token.py b/src/token.py +new file mode 100644 +index 0000000..abc1234 +--- /dev/null ++++ b/src/token.py +@@ -0,0 +1,2 @@ ++# SPDX-License-Identifier: Apache-2.0 ++JWT_TOKEN = "eyJTESTFIXTURE0.eyJTESTFIXTURE00000000.TESTFIXTURESIG-NotARealJwt" diff --git a/tests/sdk-review/test_extended.bats b/tests/sdk-review/test_extended.bats new file mode 100644 index 00000000..aae21a65 --- /dev/null +++ b/tests/sdk-review/test_extended.bats @@ -0,0 +1,439 @@ +#!/usr/bin/env bats +# Extended test suite — tier gating, suppression, integration flows, edge cases. + +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" +} + +# ============================================================ +# TIER GATING (aggregate.sh applies rules.yaml tiers) +# ============================================================ + +@test "aggregate: SHADOW rule findings moved to shadow_findings, not posted" { + tmpd=$(mktemp -d) + cat > "$tmpd/report-test.json" <<'EOF' +{ + "check": "test", + "version": "1.0.0", + "language": "python", + "status": "FLAG", + "started_at": "2026-01-01T00:00:00Z", + "duration_ms": 0, + "modules_analysed": [], + "findings": [ + {"rule": "PR-SIZE-01", "severity": "FLAG", "file": ".", "line": 1, "message": "big", "suggestion": ""}, + {"rule": "SEC-01", "severity": "BLOCK", "file": "x", "line": 1, "message": "aws", "suggestion": ""} + ], + "summary": {"block_count": 1, "flag_count": 1} +} +EOF + result=$(RULES_YAML="$CONFIG_DIR/rules.yaml" bash "$SCRIPT_DIR/aggregate.sh" "$tmpd") + # PR-SIZE-01 is SHADOW → goes to shadow_findings + echo "$result" | jq -e '.shadow_findings | length == 1' + echo "$result" | jq -e '.shadow_findings[0].rule == "PR-SIZE-01"' + # SEC-01 is BLOCK_LOCKED → stays in findings with locked=true + echo "$result" | jq -e '.findings | length == 1' + echo "$result" | jq -e '.findings[0].locked == true' + echo "$result" | jq -e '.summary.block_count == 1' + echo "$result" | jq -e '.summary.shadow_count == 1' + echo "$result" | jq -e '.summary.locked_count == 1' + rm -rf "$tmpd" +} + +@test "aggregate: unknown rule defaults to FLAG tier" { + tmpd=$(mktemp -d) + cat > "$tmpd/report-test.json" <<'EOF' +{ + "check": "test", "version": "1.0.0", "language": "python", "status": "FLAG", + "started_at": "2026-01-01T00:00:00Z", "duration_ms": 0, "modules_analysed": [], + "findings": [ + {"rule": "UNKNOWN-99", "severity": "BLOCK", "file": "x", "line": 1, "message": "m", "suggestion": ""} + ], + "summary": {"block_count": 1, "flag_count": 0} +} +EOF + result=$(RULES_YAML="$CONFIG_DIR/rules.yaml" bash "$SCRIPT_DIR/aggregate.sh" "$tmpd") + echo "$result" | jq -e '.findings[0].severity == "FLAG"' + rm -rf "$tmpd" +} + +@test "aggregate: BLOCK_LOCKED rules cannot be softened" { + tmpd=$(mktemp -d) + cat > "$tmpd/report-test.json" <<'EOF' +{ + "check": "test", "version": "1.0.0", "language": "python", "status": "FLAG", + "started_at": "2026-01-01T00:00:00Z", "duration_ms": 0, "modules_analysed": [], + "findings": [ + {"rule": "BND-02", "severity": "FLAG", "file": "x", "line": 1, "message": "token concat", "suggestion": ""} + ], + "summary": {"block_count": 0, "flag_count": 1} +} +EOF + result=$(RULES_YAML="$CONFIG_DIR/rules.yaml" bash "$SCRIPT_DIR/aggregate.sh" "$tmpd") + echo "$result" | jq -e '.findings[0].severity == "BLOCK"' + echo "$result" | jq -e '.findings[0].tier == "BLOCK_LOCKED"' + echo "$result" | jq -e '.findings[0].locked == true' + rm -rf "$tmpd" +} + +# ============================================================ +# SUPPRESSION (apply-suppression.sh) +# ============================================================ + +@test "suppression: parse_line detects multiple checks in ignore[a,b]" { + tmp=$(mktemp) + echo 'x = 1 # sdk-review: ignore[hardcode,patterns]' > "$tmp" + result=$(bash "$SCRIPT_DIR/lib/suppression.sh" parse_line "$tmp") + echo "$result" | grep -q ":1:hardcode" + echo "$result" | grep -q ":1:patterns" + rm -f "$tmp" +} + +@test "suppression: parse_line ignore without brackets = wildcard" { + tmp=$(mktemp) + echo 'x = 1 # sdk-review: ignore' > "$tmp" + result=$(bash "$SCRIPT_DIR/lib/suppression.sh" parse_line "$tmp") + echo "$result" | grep -q ":1:\*" + rm -f "$tmp" +} + +@test "suppression: is_suppressed returns true when rule matches line" { + supp_file=$(mktemp) + echo "src/x.py:42:HC-01" > "$supp_file" + result=$(bash "$SCRIPT_DIR/lib/suppression.sh" is_suppressed "HC-01" "src/x.py" "42" "$supp_file") + [ "$result" = "true" ] + rm -f "$supp_file" +} + +@test "suppression: is_suppressed returns true when wildcard on line" { + supp_file=$(mktemp) + echo "src/x.py:42:*" > "$supp_file" + result=$(bash "$SCRIPT_DIR/lib/suppression.sh" is_suppressed "ANY-RULE" "src/x.py" "42" "$supp_file") + [ "$result" = "true" ] + rm -f "$supp_file" +} + +@test "suppression: is_suppressed false on locked rule (SEC-01)" { + supp_file=$(mktemp) + echo "src/x.py:42:SEC-01" > "$supp_file" + result=$(bash "$SCRIPT_DIR/lib/suppression.sh" is_suppressed "SEC-01" "src/x.py" "42" "$supp_file") + [ "$result" = "false" ] + rm -f "$supp_file" +} + +@test "suppression: is_suppressed false on locked rule (BND-02)" { + supp_file=$(mktemp) + echo "src/x.py:42:BND-02" > "$supp_file" + result=$(bash "$SCRIPT_DIR/lib/suppression.sh" is_suppressed "BND-02" "src/x.py" "42" "$supp_file") + [ "$result" = "false" ] + rm -f "$supp_file" +} + +@test "suppression: file-level suppression matches any line" { + supp_file=$(mktemp) + echo "src/x.py:*:hardcode" > "$supp_file" + result=$(bash "$SCRIPT_DIR/lib/suppression.sh" is_suppressed "hardcode" "src/x.py" "999" "$supp_file") + [ "$result" = "true" ] + rm -f "$supp_file" +} + +@test "apply-suppression: filters findings correctly" { + supp_file=$(mktemp) + echo "src/x.py:10:HC-01" > "$supp_file" + + report=$(mktemp) + cat > "$report" <<'EOF' +{ + "check": "hardcode", "version": "1.0.0", "language": "python", "status": "BLOCK", + "started_at": "2026-01-01T00:00:00Z", "duration_ms": 0, "modules_analysed": [], + "findings": [ + {"rule": "HC-01", "severity": "BLOCK", "file": "src/x.py", "line": 10, "message": "url", "suggestion": ""}, + {"rule": "HC-01", "severity": "BLOCK", "file": "src/y.py", "line": 5, "message": "url", "suggestion": ""} + ], + "summary": {"block_count": 2, "flag_count": 0} +} +EOF + result=$(bash "$SCRIPT_DIR/lib/apply-suppression.sh" apply "$report" "$supp_file") + # x.py:10 suppressed, y.py:5 kept + echo "$result" | jq -e '.findings | length == 1' + echo "$result" | jq -e '.findings[0].file == "src/y.py"' + echo "$result" | jq -e '.summary.suppressed_count == 1' + rm -f "$supp_file" "$report" +} + +# ============================================================ +# JSON CONTRACT +# ============================================================ + +@test "json-emit: emit_report handles empty findings" { + source "$SCRIPT_DIR/lib/json-emit.sh" + result=$(echo -n "" | emit_report "test" "python" "PASS" "2026-01-01T00:00:00Z") + echo "$result" | jq -e '.findings | length == 0' + echo "$result" | jq -e '.summary.block_count == 0' + echo "$result" | jq -e '.summary.flag_count == 0' +} + +@test "json-emit: status_from_findings returns BLOCK when any BLOCK finding" { + source "$SCRIPT_DIR/lib/json-emit.sh" + result=$(printf '{"severity":"FLAG"}\n{"severity":"BLOCK"}\n' | status_from_findings) + [ "$result" = "BLOCK" ] +} + +@test "json-emit: status_from_findings returns PASS when empty" { + source "$SCRIPT_DIR/lib/json-emit.sh" + result=$(echo -n "" | status_from_findings) + [ "$result" = "PASS" ] +} + +# ============================================================ +# DIFF ATTRIBUTION EDGE CASES +# ============================================================ + +@test "diff-added-lines: handles multiple hunks in one file" { + cat > /tmp/multi.diff <<'EOF' +diff --git a/src/a.py b/src/a.py +--- a/src/a.py ++++ b/src/a.py +@@ -1,3 +1,4 @@ + line1 ++added_first + line2 +@@ -10,3 +11,4 @@ + line10 ++added_second + line11 +EOF + result=$(bash "$SCRIPT_DIR/lib/diff-added-lines.sh" < /tmp/multi.diff) + echo "$result" | grep -q "src/a.py:2" + echo "$result" | grep -q "src/a.py:12" + rm -f /tmp/multi.diff +} + +@test "diff-added-lines: multiple files" { + cat > /tmp/multi-file.diff <<'EOF' +diff --git a/a.py b/a.py +--- a/a.py ++++ b/a.py +@@ -1,1 +1,2 @@ + x ++new_in_a +diff --git a/b.py b/b.py +--- a/b.py ++++ b/b.py +@@ -1,1 +1,2 @@ + y ++new_in_b +EOF + result=$(bash "$SCRIPT_DIR/lib/diff-added-lines.sh" < /tmp/multi-file.diff) + echo "$result" | grep -q "a.py:2" + echo "$result" | grep -q "b.py:2" + rm -f /tmp/multi-file.diff +} + +# ============================================================ +# PREDICATES EDGE CASES +# ============================================================ + +@test "predicates: module_shape returns 'client' for module with client.py" { + source "$SCRIPT_DIR/lib/predicates.sh" + tmpd=$(mktemp -d) + touch "$tmpd/client.py" + result=$(module_shape "$tmpd") + [ "$result" = "client" ] + rm -rf "$tmpd" +} + +@test "predicates: module_shape returns 'patch' for module with _patch.py only" { + source "$SCRIPT_DIR/lib/predicates.sh" + tmpd=$(mktemp -d) + touch "$tmpd/_patch.py" + result=$(module_shape "$tmpd") + [ "$result" = "patch" ] + rm -rf "$tmpd" +} + +@test "predicates: module_shape returns 'other' for empty dir" { + source "$SCRIPT_DIR/lib/predicates.sh" + tmpd=$(mktemp -d) + result=$(module_shape "$tmpd") + [ "$result" = "other" ] + rm -rf "$tmpd" +} + +# ============================================================ +# AST EDGE CASES +# ============================================================ + +@test "ast: EL-02 accepts except: ...; return e" { + cat > /tmp/ok.py <<'EOF' +def f(): + try: + pass + except Exception as e: + return e +EOF + result=$(python3 "$SCRIPT_DIR/lib/ast_python_checks.py" el-02 /tmp/ok.py) + [ -z "$result" ] + rm -f /tmp/ok.py +} + +@test "ast: EL-02 flags except: pass" { + cat > /tmp/bad.py <<'EOF' +def f(): + try: + do_work() + except Exception: + pass +EOF + result=$(python3 "$SCRIPT_DIR/lib/ast_python_checks.py" el-02 /tmp/bad.py) + echo "$result" | jq -e '.rule == "PY-EL-02"' + rm -f /tmp/bad.py +} + +@test "ast: TEL-02 skips private methods" { + cat > /tmp/client.py <<'EOF' +class MyClient: + def _internal(self): + pass +EOF + result=$(python3 "$SCRIPT_DIR/lib/ast_python_checks.py" tel-02 /tmp/client.py) + [ -z "$result" ] + rm -f /tmp/client.py +} + +@test "ast: TEL-02 skips non-Client classes" { + cat > /tmp/model.py <<'EOF' +class DataModel: + def get_value(self): + pass +EOF + result=$(python3 "$SCRIPT_DIR/lib/ast_python_checks.py" tel-02 /tmp/model.py) + [ -z "$result" ] + rm -f /tmp/model.py +} + +@test "ast: CON-01 does not flag URLs" { + cat > /tmp/const.py <<'EOF' +a = "https://api.example.com" +b = "https://api.example.com" +c = "https://api.example.com" +EOF + result=$(python3 "$SCRIPT_DIR/lib/ast_python_checks.py" con-01 /tmp/const.py) + [ -z "$result" ] + rm -f /tmp/const.py +} + +# ============================================================ +# BASELINE +# ============================================================ + +@test "baseline: repo-scope exempts all files" { + baseline='{"exemptions":{"PY-LIC-01":{"scope":"repo"}}}' + source "$SCRIPT_DIR/lib/baseline.sh" + result=$(is_exempted "PY-LIC-01" "src/anything.py" "$baseline") + [ "$result" = "true" ] +} + +@test "baseline: module scope exempts only matching module" { + baseline='{"exemptions":{"TEL-06":{"scope":"module:adms"}}}' + source "$SCRIPT_DIR/lib/baseline.sh" + in_module=$(is_exempted "TEL-06" "src/sap_cloud_sdk/adms/foo.py" "$baseline") + [ "$in_module" = "true" ] + other=$(is_exempted "TEL-06" "src/sap_cloud_sdk/destination/foo.py" "$baseline") + [ "$other" = "false" ] +} + +@test "baseline: unknown rule not exempted" { + baseline='{"exemptions":{"OTHER":{"scope":"repo"}}}' + source "$SCRIPT_DIR/lib/baseline.sh" + result=$(is_exempted "HC-01" "src/x.py" "$baseline") + [ "$result" = "false" ] +} + +# ============================================================ +# INTEGRATION FLOWS +# ============================================================ + +@test "orchestrate: empty diff produces summary with 0 findings" { + tmpd=$(mktemp -d) + cp -r "$REPO_ROOT/.claude" "$tmpd/" + cd "$tmpd" + echo "[project]" > pyproject.toml + mkdir -p src/sap_cloud_sdk + echo "" > /tmp/empty.diff + + set +e + DRY_RUN=true LOCAL_DIFF=/tmp/empty.diff REPO_ROOT="$tmpd" \ + bash .claude/scripts/orchestrate.sh 999 --dry-run > /tmp/out.txt 2>&1 + status=$? + set -e + [ "$status" -eq 0 ] + grep -q "BLOCK: 0" /tmp/out.txt + rm -rf "$tmpd" /tmp/empty.diff /tmp/out.txt +} + +@test "orchestrate: dry-run generates valid summary.json" { + tmpd=$(mktemp -d) + cp -r "$REPO_ROOT/.claude" "$tmpd/" + cd "$tmpd" + echo "[project]" > pyproject.toml + mkdir -p src/sap_cloud_sdk + + KEEP_TMP=1 DRY_RUN=true LOCAL_DIFF="$FIXTURES/clean.diff" REPO_ROOT="$tmpd" TMPDIR_RUN=/tmp/orch-test \ + bash .claude/scripts/orchestrate.sh 999 --dry-run > /dev/null 2>&1 || true + [ -f /tmp/orch-test/summary.json ] + jq -e '.summary.block_count != null' /tmp/orch-test/summary.json + jq -e '.per_check_summary' /tmp/orch-test/summary.json + rm -rf "$tmpd" /tmp/orch-test +} + +# ============================================================ +# SPECIFIC CHECK EDGE CASES +# ============================================================ + +@test "check-secrets: does not fire on placeholder-looking test fixtures" { + # test data that looks like a key but is a test placeholder + cat > /tmp/test-fixture.diff <<'EOF' +diff --git a/tests/fixtures/token.txt b/tests/fixtures/token.txt +new file mode 100644 +index 0000000..abc +--- /dev/null ++++ b/tests/fixtures/token.txt +@@ -0,0 +1 @@ ++test-token-placeholder +EOF + DIFF_FILE=/tmp/test-fixture.diff bash "$SCRIPT_DIR/check-secrets.sh" < /tmp/test-fixture.diff > /tmp/out.json + # Should not fire on obvious placeholder + jq -e '.status == "PASS"' /tmp/out.json + rm -f /tmp/test-fixture.diff /tmp/out.json +} + +@test "check-hardcode: exempts localhost and example.com" { + cat > /tmp/local.diff <<'EOF' +diff --git a/src/sap_cloud_sdk/mymod/client.py b/src/sap_cloud_sdk/mymod/client.py +index 111..222 +--- a/src/sap_cloud_sdk/mymod/client.py ++++ b/src/sap_cloud_sdk/mymod/client.py +@@ -1,1 +1,2 @@ + x ++URL = "https://localhost:8080/api" +EOF + DIFF_FILE=/tmp/local.diff bash "$SCRIPT_DIR/check-hardcode.sh" < /tmp/local.diff > /tmp/out.json + # localhost is allowlisted + jq -e '[.findings[] | select(.rule == "HC-01")] | length == 0' /tmp/out.json + rm -f /tmp/local.diff /tmp/out.json +} + +@test "check-disclosure: PR body Closes #<issue_number> triggers DIS-07" { + pr_body=$(mktemp) + echo "Closes #<issue_number>" > "$pr_body" + cat > /tmp/empty.diff <<'EOF' +EOF + DIFF_FILE=/tmp/empty.diff PR_BODY_FILE="$pr_body" bash "$SCRIPT_DIR/check-disclosure.sh" < /tmp/empty.diff > /tmp/out.json + jq -e '.findings | map(.rule) | any(. == "DIS-07")' /tmp/out.json + rm -f "$pr_body" /tmp/empty.diff /tmp/out.json +} diff --git a/tests/sdk-review/test_smoke.bats b/tests/sdk-review/test_smoke.bats new file mode 100644 index 00000000..3eee774a --- /dev/null +++ b/tests/sdk-review/test_smoke.bats @@ -0,0 +1,274 @@ +#!/usr/bin/env bats +# smoke tests for the SDK Module Review skill + +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" +} + +# --- LIBS --- + +@test "lib/json-emit.sh: emit_finding produces valid JSON" { + source "$SCRIPT_DIR/lib/json-emit.sh" + result=$(emit_finding "TEST-01" "BLOCK" "src/x.py" 42 "test message") + echo "$result" | jq -e '.rule == "TEST-01"' + echo "$result" | jq -e '.severity == "BLOCK"' + echo "$result" | jq -e '.line == 42' +} + +@test "lib/json-emit.sh: emit_report assembles findings correctly" { + source "$SCRIPT_DIR/lib/json-emit.sh" + result=$(printf '{"rule":"A","severity":"BLOCK","file":"f","line":1,"message":"m","suggestion":""}\n{"rule":"B","severity":"FLAG","file":"g","line":2,"message":"m","suggestion":""}\n' | emit_report "test" "python" "BLOCK" "2026-01-01T00:00:00Z") + echo "$result" | jq -e '.check == "test"' + echo "$result" | jq -e '.findings | length == 2' + echo "$result" | jq -e '.summary.block_count == 1' + echo "$result" | jq -e '.summary.flag_count == 1' +} + +@test "lib/diff-added-lines.sh: extracts only added lines" { + cat > /tmp/test.diff <<'EOF' +diff --git a/src/a.py b/src/a.py +--- a/src/a.py ++++ b/src/a.py +@@ -1,3 +1,4 @@ + context1 ++added1 + context2 ++added2 +EOF + result=$(bash "$SCRIPT_DIR/lib/diff-added-lines.sh" < /tmp/test.diff) + echo "$result" | grep -q "src/a.py:2" + echo "$result" | grep -q "src/a.py:4" + ! echo "$result" | grep -q "context" +} + +@test "lib/detect-language.sh: detects Python from pyproject.toml" { + tmpd=$(mktemp -d) + echo "[project]" > "$tmpd/pyproject.toml" + mkdir -p "$tmpd/src/sap_cloud_sdk" + result=$(bash "$SCRIPT_DIR/lib/detect-language.sh" "$tmpd") + [ "$result" = "python" ] + rm -rf "$tmpd" +} + +@test "lib/predicates.sh: reuse_toml_aggregate_present returns true when REUSE.toml aggregate exists" { + source "$SCRIPT_DIR/lib/predicates.sh" + tmpd=$(mktemp -d) + cat > "$tmpd/REUSE.toml" <<'EOF' +version = 1 +[[annotations]] +path = "**" +SPDX-License-Identifier = "Apache-2.0" +EOF + result=$(reuse_toml_aggregate_present "$tmpd") + [ "$result" = "true" ] + rm -rf "$tmpd" +} + +@test "lib/predicates.sh: reuse_toml_aggregate_present false when no REUSE.toml" { + source "$SCRIPT_DIR/lib/predicates.sh" + tmpd=$(mktemp -d) + result=$(reuse_toml_aggregate_present "$tmpd") + [ "$result" = "false" ] + rm -rf "$tmpd" +} + +@test "lib/suppression.sh: parse_line detects ignore[hardcode]" { + tmp=$(mktemp) + cat > "$tmp" <<'EOF' +line1 +timeout = 30 # sdk-review: ignore[hardcode] +line3 +EOF + result=$(bash "$SCRIPT_DIR/lib/suppression.sh" parse_line "$tmp") + echo "$result" | grep -q ":2:hardcode" + rm -f "$tmp" +} + +@test "lib/suppression.sh: locked rules refuse suppression" { + tmp=$(mktemp) + echo "$tmp:1:SEC-01" > "$tmp.supp" + result=$(bash "$SCRIPT_DIR/lib/suppression.sh" is_suppressed "SEC-01" "$tmp" 1 "$tmp.supp") + [ "$result" = "false" ] + rm -f "$tmp" "$tmp.supp" +} + +# --- CHECKS --- + +@test "check-secrets: fires BLOCK on AWS access key" { + DIFF_FILE="$FIXTURES/secrets-aws-key.diff" bash "$SCRIPT_DIR/check-secrets.sh" < "$FIXTURES/secrets-aws-key.diff" > /tmp/out.json + jq -e '.status == "BLOCK"' /tmp/out.json + jq -e '.findings[0].rule == "SEC-01"' /tmp/out.json +} + +@test "check-secrets: fires BLOCK on JWT token" { + DIFF_FILE="$FIXTURES/secrets-jwt.diff" bash "$SCRIPT_DIR/check-secrets.sh" < "$FIXTURES/secrets-jwt.diff" > /tmp/out.json + jq -e '.status == "BLOCK"' /tmp/out.json + jq -e '.findings | map(.rule) | any(. == "SEC-06")' /tmp/out.json +} + +@test "check-secrets: PASS on clean diff" { + DIFF_FILE="$FIXTURES/clean.diff" bash "$SCRIPT_DIR/check-secrets.sh" < "$FIXTURES/clean.diff" > /tmp/out.json + jq -e '.status == "PASS"' /tmp/out.json +} + +@test "check-hardcode: fires BLOCK on hardcoded URL" { + DIFF_FILE="$FIXTURES/hardcode-url.diff" bash "$SCRIPT_DIR/check-hardcode.sh" < "$FIXTURES/hardcode-url.diff" > /tmp/out.json + jq -e '.findings | length > 0' /tmp/out.json + jq -e '.findings | map(.rule) | any(. == "HC-01")' /tmp/out.json +} + +@test "check-disclosure: fires on SAP-internal URL (public profile)" { + DISCLOSURE_PROFILE=public DIFF_FILE="$FIXTURES/disclosure-internal.diff" bash "$SCRIPT_DIR/check-disclosure.sh" < "$FIXTURES/disclosure-internal.diff" > /tmp/out.json + jq -e '.findings | map(.rule) | any(. == "DIS-01")' /tmp/out.json + jq -e '.findings[0].severity == "BLOCK"' /tmp/out.json +} + +@test "check-disclosure: internal profile downgrades DIS-01 to FLAG" { + DISCLOSURE_PROFILE=internal DIFF_FILE="$FIXTURES/disclosure-internal.diff" bash "$SCRIPT_DIR/check-disclosure.sh" < "$FIXTURES/disclosure-internal.diff" > /tmp/out.json + jq -e '.findings[0].severity == "FLAG"' /tmp/out.json +} + +@test "check-license-spdx: REUSE.toml aggregate exempts LIC-01" { + tmpd=$(mktemp -d) + cat > "$tmpd/REUSE.toml" <<'EOF' +version = 1 +[[annotations]] +path = "**" +SPDX-License-Identifier = "Apache-2.0" +EOF + REPO_ROOT="$tmpd" DIFF_FILE="$FIXTURES/secrets-aws-key.diff" bash "$SCRIPT_DIR/check-license-spdx.sh" < "$FIXTURES/secrets-aws-key.diff" > /tmp/out.json + jq -e '.status == "PASS"' /tmp/out.json + rm -rf "$tmpd" +} + +@test "check-license-spdx: fires BLOCK on new .py without SPDX header (no REUSE.toml)" { + cat > /tmp/no-spdx.diff <<'EOF' +diff --git a/src/new.py b/src/new.py +new file mode 100644 +index 0000000..abc +--- /dev/null ++++ b/src/new.py +@@ -0,0 +1,2 @@ ++def foo(): ++ pass +EOF + tmpd=$(mktemp -d) + REPO_ROOT="$tmpd" DIFF_FILE=/tmp/no-spdx.diff bash "$SCRIPT_DIR/check-license-spdx.sh" < /tmp/no-spdx.diff > /tmp/out.json + jq -e '.status == "BLOCK"' /tmp/out.json + jq -e '.findings | map(.rule) | any(. == "LIC-01")' /tmp/out.json + rm -rf "$tmpd" +} + +@test "check-binding-shape: fires BLOCK_LOCKED on /oauth/token concat" { + LANGUAGE=java DIFF_FILE="$FIXTURES/binding-token-concat.diff" bash "$SCRIPT_DIR/check-binding-shape.sh" < "$FIXTURES/binding-token-concat.diff" > /tmp/out.json + jq -e '.status == "BLOCK"' /tmp/out.json + jq -e '.findings | map(.rule) | any(. == "BND-02")' /tmp/out.json +} + +# --- AST checks --- + +@test "ast-python-checks: EL-01 fires on raising different exception without from e" { + cat > /tmp/bad.py <<'EOF' +def x(): + try: + pass + except ValueError as e: + raise KeyError("nope") +EOF + result=$(python3 "$SCRIPT_DIR/lib/ast_python_checks.py" el-01 /tmp/bad.py) + echo "$result" | jq -e '.rule == "PY-EL-01"' + rm -f /tmp/bad.py +} + +@test "ast-python-checks: EL-01 PASSES on bare raise" { + cat > /tmp/good.py <<'EOF' +def x(): + try: + pass + except ValueError: + raise +EOF + result=$(python3 "$SCRIPT_DIR/lib/ast_python_checks.py" el-01 /tmp/good.py) + [ -z "$result" ] + rm -f /tmp/good.py +} + +@test "ast-python-checks: EL-01 PASSES on raise X from e" { + cat > /tmp/good.py <<'EOF' +def x(): + try: + pass + except ValueError as e: + raise KeyError("wrapped") from e +EOF + result=$(python3 "$SCRIPT_DIR/lib/ast_python_checks.py" el-01 /tmp/good.py) + [ -z "$result" ] + rm -f /tmp/good.py +} + +@test "ast-python-checks: TEL-02 fires on public *Client method without @record_metrics" { + cat > /tmp/client.py <<'EOF' +class MyClient: + def do_thing(self): + pass +EOF + result=$(python3 "$SCRIPT_DIR/lib/ast_python_checks.py" tel-02 /tmp/client.py) + echo "$result" | jq -e '.rule == "PY-TEL-02"' + rm -f /tmp/client.py +} + +@test "ast-python-checks: TEL-02 PASSES when @record_metrics present" { + cat > /tmp/client.py <<'EOF' +class MyClient: + @record_metrics(Module.X, Operation.Y) + def do_thing(self): + pass +EOF + result=$(python3 "$SCRIPT_DIR/lib/ast_python_checks.py" tel-02 /tmp/client.py) + [ -z "$result" ] + rm -f /tmp/client.py +} + +@test "ast-python-checks: CON-01 fires on 3× repeated string" { + cat > /tmp/const.py <<'EOF' +def x(): + a = "com.sap.adm.DocumentService" + b = "com.sap.adm.DocumentService" + c = "com.sap.adm.DocumentService" +EOF + result=$(python3 "$SCRIPT_DIR/lib/ast_python_checks.py" con-01 /tmp/const.py) + echo "$result" | jq -e '.rule == "PY-CON-01"' + rm -f /tmp/const.py +} + +# --- Full orchestrator dry-run --- + +@test "orchestrate.sh: dry-run against secrets fixture exits 1 (BLOCK)" { + tmpd=$(mktemp -d) + cp -r "$REPO_ROOT/.claude" "$tmpd/" + cd "$tmpd" + echo "[project]" > pyproject.toml + mkdir -p src/sap_cloud_sdk + set +e + DRY_RUN=true LOCAL_DIFF="$FIXTURES/secrets-aws-key.diff" REPO_ROOT="$tmpd" \ + bash .claude/scripts/orchestrate.sh 999 --dry-run > /dev/null 2>&1 + status=$? + set -e + [ "$status" -eq 1 ] + rm -rf "$tmpd" +} + +@test "orchestrate.sh: dry-run against clean fixture exits 0" { + tmpd=$(mktemp -d) + cp -r "$REPO_ROOT/.claude" "$tmpd/" + cd "$tmpd" + echo "[project]" > pyproject.toml + mkdir -p src/sap_cloud_sdk + DRY_RUN=true LOCAL_DIFF="$FIXTURES/clean.diff" REPO_ROOT="$tmpd" \ + bash .claude/scripts/orchestrate.sh 999 --dry-run + rm -rf "$tmpd" +} diff --git a/uv.lock b/uv.lock index be9e3cfa..4a93df76 100644 --- a/uv.lock +++ b/uv.lock @@ -3713,7 +3713,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.32.0" +version = "0.32.1" source = { editable = "." } dependencies = [ { name = "grpcio" },