diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1f2f2c7..e9853c3 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -30,8 +30,8 @@ A reusable workflow cannot reliably check out *its own* repo's scripts: in a `wo `scripts/*.sh` is the source of truth; the inline copy is a derived artifact. **Editing a script means updating its inline copy too**, or `check-inline-sync.sh` fails CI. The sync is byte-for-byte after known normalizations (10-space YAML indent strip, shebang strip, function-wrapper strip). The pairs are listed in `check-inline-sync.sh` (`INLINE_PAIRS`): -- `dependency-cooldown.yml` embeds `extract-deps.sh`, `check-release-age.sh`, `diff-touches-lockfile.sh`, `pr-body-to-deps.sh` -- `dependency-safety.yml` embeds the same four scripts plus `safety-verdict.sh` +- `dependency-cooldown.yml` embeds `extract-deps.sh`, `check-release-age.sh`, `diff-touches-lockfile.sh`, `pr-body-to-deps.sh`, `classify-touched-paths.sh`, `pyproject-bump-extract.sh` +- `dependency-safety.yml` embeds the same six scripts plus `safety-verdict.sh` - `tag-release.yml` embeds `bump-version-files.sh` `lint-workflow-call.sh` is the partner guard: it fails CI if any `workflow_call` file reintroduces a caller-scoped ref as a checkout `ref:`. diff --git a/.github/workflows/dependency-cooldown.yml b/.github/workflows/dependency-cooldown.yml index e36ca49..490fb66 100644 --- a/.github/workflows/dependency-cooldown.yml +++ b/.github/workflows/dependency-cooldown.yml @@ -289,6 +289,13 @@ jobs: # unsupported and printed to stdout. Layer 2 (PR-body fallback) may still # recover deps from some of these for the scan loop, but the guard fires # because the diff parser cannot prove the scan was complete. + # + # pyproject.toml is path-level unsupported here and may be cleared by + # scripts/pyproject-bump-extract.sh at the workflow composition layer + # when its hunks are proven to be bump-only. This script remains + # path-only and intentionally conservative; the final unsupported set + # in the workflow is produced by composition, not by this classifier + # alone. set -euo pipefail @@ -311,6 +318,494 @@ jobs: ) # --- END inline:scripts/classify-touched-paths.sh --- + # --- BEGIN inline:scripts/pyproject-bump-extract.sh --- + pyproject_bump_extract() ( + # pyproject-bump-extract.sh — diff-aware extractor for pyproject.toml bump-only edits. + # + # Owns all pyproject.toml diff semantics for the dependency-cooldown/safety + # workflows. Recognizes the narrow set of bump shapes Dependabot emits for + # uv/poetry ecosystems and emits either extracted dep rows (mode=deps) or + # the paths it proved are bump-only (mode=cleared-paths). Files with any + # unparseable changed line (build-system edits, new-dep additions, marker + # changes, etc.) are disqualified and left unsupported so the existing + # fail-loud guard fires. + # + # Input: unified diff on stdin + # Flag: --mode=deps OR --mode=cleared-paths (exactly one, required) + # Output (deps): TSV \t\tpypi, sorted by name, deduped + # Output (cleared-paths): newline-delimited pyproject.toml paths, sorted, deduped + # Exit: 0 on success (possibly zero rows / zero paths) + # 2 on malformed input, missing --mode, unknown mode, or repeated --mode + # + # Bash 3.2 compatible: no `declare -A`, no `mapfile`/`readarray`. Dedup uses a + # newline-delimited string sentinel, matching scripts/extract-deps.sh. + # + # See docs/superpowers/specs/2026-05-23-pyproject-toml-parser-design.md. + + set -euo pipefail + + MODE="" + for arg in "$@"; do + case "$arg" in + --mode=deps|--mode=cleared-paths) + if [ -n "$MODE" ]; then + echo "pyproject-bump-extract.sh: --mode specified more than once" >&2 + exit 2 + fi + MODE="${arg#--mode=}" + ;; + *) + echo "pyproject-bump-extract.sh: unknown argument: $arg" >&2 + exit 2 + ;; + esac + done + + if [ -z "$MODE" ]; then + echo "pyproject-bump-extract.sh: --mode=deps or --mode=cleared-paths required" >&2 + exit 2 + fi + + input=$(cat) + + # Empty input → exit 0 with no output (matches extract-deps.sh). + if [ -z "$input" ]; then + exit 0 + fi + + # Malformed input detection: here-string (not pipeline) to avoid SIGPIPE under + # pipefail on large valid diffs (issue #50 pattern). + if ! grep -qE '^(\+\+\+|---|@@|diff --git)' <<< "$input"; then + echo "pyproject-bump-extract.sh: input is not a unified diff" >&2 + exit 2 + fi + + # --- Parser state --- + # Per-file (reset on each diff --git boundary): + current_path="" + current_basename="" + current_table="" # "" | "project_other" | "project_optional_deps" | "dependency_groups" + # | "tool_uv" | "poetry_main" | "poetry_group" | "poetry_dev" + # | "build_system" | "other" + current_key="" # array-opening key for tables where keys are dep arrays + verdict="clean" + file_rows=$'\n' + + # Global: + out_deps_rows=() + out_cleared_paths=() + seen=$'\n' # dedup sentinel "\npypi:\n" + + # Single pending tracker. Lifetime = exactly one line. If pending is set, + # the IMMEDIATELY NEXT line MUST be the matching + (same kind, same name, + # same skeleton). Anything else (other -, unmatched +, context, comment, + # header, hunk boundary, file boundary, EOF) disqualifies. + pending_kind="" # "" | "pep508" | "poetry_keyval" | "poetry_inline" + pending_name="" + pending_skeleton="" # entry with version-spec field substituted by sentinel + pending_minus_version="" + + # --- Helpers --- + + # extract_target_version "$spec" — §3.3.1. Prints target on stdout, returns 1 on disqualify. + extract_target_version() { + local spec="$1" stripped + spec="${spec#"${spec%%[![:space:]]*}"}" + spec="${spec%"${spec##*[![:space:]]}"}" + case "$spec" in *,*) return 1 ;; esac + case "$spec" in + \^*) stripped="${spec#\^}" ;; + \~=*) stripped="${spec#~=}" ;; + \~*) stripped="${spec#~}" ;; + \>=*) stripped="${spec#>=}" ;; + ==*) stripped="${spec#==}" ;; + \<*|\<=*|\>*|!=*) return 1 ;; + *) stripped="$spec" ;; + esac + stripped="${stripped#"${stripped%%[![:space:]]*}"}" + stripped="${stripped%"${stripped##*[![:space:]]}"}" + if [[ "$stripped" =~ ^[0-9]+(\.[0-9]+)*((a|b|rc|alpha|beta)[0-9]*)?(\.post[0-9]+)?(\.dev[0-9]+)?$ ]]; then + printf '%s' "$stripped" + return 0 + fi + return 1 + } + + # parse_pep508_entry "$content" — captures name/extras/version_spec/marker. + # Sets _pep_name, _pep_extras (with brackets, or empty), _pep_version_spec, + # _pep_marker (with leading ';', or empty). Returns 0/1. + parse_pep508_entry() { + local content="$1" trimmed + _pep_name=""; _pep_extras=""; _pep_version_spec=""; _pep_marker="" + if [[ "$content" =~ ^[[:space:]]*\"([A-Za-z][A-Za-z0-9_.-]*)(\[[^]]*\])?([^\";]*)(;.*)?\"[[:space:]]*,?[[:space:]]*$ ]]; then + _pep_name="${BASH_REMATCH[1]}" + _pep_extras="${BASH_REMATCH[2]}" + _pep_version_spec="${BASH_REMATCH[3]}" + _pep_marker="${BASH_REMATCH[4]}" + # Validate: non-empty version_spec must start with a PEP 508 operator. + # Bare versions (e.g., "foo 1.0") are malformed PEP 508 and must not parse. + trimmed="${_pep_version_spec#"${_pep_version_spec%%[![:space:]]*}"}" + if [ -n "$trimmed" ]; then + case "$trimmed" in + \^*|\~=*|\~*|\>=*|==*|\<=*|\<*|\>*|!=*) ;; + *) return 1 ;; + esac + fi + return 0 + fi + return 1 + } + + # parse_poetry_keyval "$value" — for "spec" form. Sets _poetry_keyval_version. + parse_poetry_keyval() { + local value="$1" + _poetry_keyval_version="" + if [[ "$value" =~ ^[[:space:]]*\"([^\"]*)\"[[:space:]]*$ ]]; then + _poetry_keyval_version="${BASH_REMATCH[1]}" + return 0 + fi + return 1 + } + + # parse_poetry_inline "$value" — for { version = "spec", ... } form. + # Sets _poetry_inline_version and _poetry_inline_skeleton (version VALUE + # substituted, surrounding whitespace preserved). Requires `version` to be + # preceded by start-of-string, comma, or whitespace so `subversion` does + # not match (Bug 2). Preserves original whitespace around `=` so + # reformatting that changes spacing is caught (Bug 4). + parse_poetry_inline() { + local value="$1" inner + _poetry_inline_version=""; _poetry_inline_skeleton="" + if [[ "$value" =~ ^[[:space:]]*\{(.*)\}[[:space:]]*$ ]]; then + inner="${BASH_REMATCH[1]}" + if [[ "$inner" =~ (^|[,[:space:]])version[[:space:]]*=[[:space:]]*\"([^\"]*)\" ]]; then + _poetry_inline_version="${BASH_REMATCH[2]}" + _poetry_inline_skeleton=$(printf '%s' "$inner" | sed -E 's/(^|[,[:space:]])(version[[:space:]]*=[[:space:]]*)"[^"]*"/\1\2"__VER__"/') + return 0 + fi + fi + return 1 + } + + # extract_operator "$spec" — returns the operator portion on stdout. + # "^", "~=", "~", ">=", "==" → printed verbatim; bare version → empty string. + # Unsupported operators (<, <=, >, !=) → return 1 (caller treats as disqualify). + extract_operator() { + local spec="$1" trimmed + trimmed="${spec#"${spec%%[![:space:]]*}"}" + case "$trimmed" in + \^*) printf '%s' '^' ;; + \~=*) printf '%s' '~=' ;; + \~*) printf '%s' '~' ;; + \>=*) printf '%s' '>=' ;; + ==*) printf '%s' '==' ;; + \<=*|\<*|\>*|!=*) return 1 ;; + *) printf '%s' '' ;; + esac + return 0 + } + + emit_bump() { + local name="$1" plus_spec="$2" minus_spec="$3" target plus_op minus_op + if [ "$plus_spec" = "$minus_spec" ]; then verdict="disqualified"; return; fi + plus_op=$(extract_operator "$plus_spec") || { verdict="disqualified"; return; } + minus_op=$(extract_operator "$minus_spec") || { verdict="disqualified"; return; } + if [ "$plus_op" != "$minus_op" ]; then verdict="disqualified"; return; fi + target=$(extract_target_version "$plus_spec") || { verdict="disqualified"; return; } + extract_target_version "$minus_spec" >/dev/null || { verdict="disqualified"; return; } + file_rows="${file_rows}${name} ${target} pypi"$'\n' + } + + clear_pending() { + pending_kind="" + pending_name="" + pending_skeleton="" + pending_minus_version="" + } + + flush_pending_as_disqualified() { + if [ -n "$pending_kind" ]; then + verdict="disqualified" + clear_pending + fi + } + + flush_file() { + flush_pending_as_disqualified + if [ -z "$current_basename" ] || [ "$current_basename" != "pyproject.toml" ]; then + return + fi + if [ "$verdict" = "clean" ]; then + while IFS= read -r row; do + [ -z "$row" ] && continue + local name="${row%% *}" + local key="pypi:$name" + case "$seen" in *$'\n'"$key"$'\n'*) continue ;; esac + seen="${seen}${key}"$'\n' + out_deps_rows+=("$row") + done <<< "$file_rows" + out_cleared_paths+=("$current_path") + fi + } + + reset_file_state() { + current_table="" + current_key="" + verdict="clean" + file_rows=$'\n' + clear_pending + } + + # --- Main parse loop --- + while IFS= read -r line; do + line="${line%$'\r'}" + + # File boundary. + if [[ "$line" =~ ^diff[[:space:]]--git[[:space:]]a/([^[:space:]]+)[[:space:]]b/([^[:space:]]+) ]]; then + flush_file + current_path="${BASH_REMATCH[2]}" + current_basename="${current_path##*/}" + reset_file_state + continue + fi + + [[ "$line" == +++* ]] && continue + [[ "$line" == ---* ]] && continue + + if [[ "$line" =~ ^@@ ]]; then + flush_pending_as_disqualified + current_table="" + current_key="" + continue + fi + + [ "$current_basename" != "pyproject.toml" ] && continue + [ "$verdict" = "disqualified" ] && continue + + prefix="${line:0:1}" + content="${line:1}" + + # ---------------- Centralized pending consumption ---------------- + # If pending is set, this line MUST be the matching + (same kind/name/skeleton) + # or the file is disqualified. No other line type may consume or pass through + # pending state. + if [ -n "$pending_kind" ]; then + consumed=false + if [ "$prefix" = "+" ]; then + case "$pending_kind" in + pep508) + if parse_pep508_entry "$content"; then + plus_skeleton="${_pep_name}${_pep_extras}__VER__${_pep_marker}" + if [ "$_pep_name" = "$pending_name" ] && [ "$plus_skeleton" = "$pending_skeleton" ]; then + emit_bump "$_pep_name" "$_pep_version_spec" "$pending_minus_version" + consumed=true + fi + fi + ;; + poetry_keyval) + if [[ "$content" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_-]*)[[:space:]]*=[[:space:]]*(.*)$ ]]; then + plus_key="${BASH_REMATCH[1]}" + plus_value="${BASH_REMATCH[2]}" + if [ "$plus_key" = "$pending_name" ] && parse_poetry_keyval "$plus_value"; then + emit_bump "$plus_key" "$_poetry_keyval_version" "$pending_minus_version" + consumed=true + fi + fi + ;; + poetry_inline) + if [[ "$content" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_-]*)[[:space:]]*=[[:space:]]*(.*)$ ]]; then + plus_key="${BASH_REMATCH[1]}" + plus_value="${BASH_REMATCH[2]}" + if [ "$plus_key" = "$pending_name" ] && parse_poetry_inline "$plus_value"; then + if [ "$_poetry_inline_skeleton" = "$pending_skeleton" ]; then + emit_bump "$plus_key" "$_poetry_inline_version" "$pending_minus_version" + consumed=true + fi + fi + fi + ;; + esac + fi + clear_pending + if [ "$consumed" = "false" ]; then + verdict="disqualified" + fi + continue + fi + # ---------------- End pending consumption ---------------- + + # Comment / whitespace allowance anywhere (§3.4 rule 2). + if [[ "$content" =~ ^[[:space:]]*# ]]; then continue; fi + if [[ "$content" =~ ^[[:space:]]*$ ]]; then continue; fi + + # Table header detection. + if [[ "$content" =~ ^[[:space:]]*\[([^]]+)\] ]]; then + header="${BASH_REMATCH[1]}" + case "$header" in + project) current_table="project_other"; current_key="" ;; + project.optional-dependencies) current_table="project_optional_deps"; current_key="" ;; + dependency-groups) current_table="dependency_groups"; current_key="" ;; + tool.uv) current_table="tool_uv"; current_key="" ;; + tool.poetry.dependencies) current_table="poetry_main"; current_key="" ;; + tool.poetry.dev-dependencies) current_table="poetry_dev"; current_key="" ;; + build-system) current_table="build_system"; current_key="" ;; + *) + case "$header" in + tool.poetry.group.*.dependencies) current_table="poetry_group"; current_key="" ;; + *) current_table="other"; current_key="" ;; + esac + ;; + esac + # Changed table header = structural change → disqualify. + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + continue + fi + + # Key = ... line — per-table routing. + if [[ "$content" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_-]*)[[:space:]]*=[[:space:]]*(.*)$ ]]; then + key="${BASH_REMATCH[1]}" + value="${BASH_REMATCH[2]}" + case "$current_table" in + project_other) + if [ "$key" = "dependencies" ]; then + # `dependencies = [` on +/- = structural change. + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; continue; fi + current_key="dependencies" + else + # Any other [project] key on +/- (name, version, description, ...) disqualifies. + current_key="" + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + fi + ;; + project_optional_deps|dependency_groups) + # Adding/renaming an extras key or dep group is structural. + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; continue; fi + current_key="$key" + ;; + tool_uv) + case "$key" in + constraint-dependencies|override-dependencies) + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; continue; fi + current_key="$key" + ;; + *) + current_key="" + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + ;; + esac + ;; + poetry_main|poetry_group|poetry_dev) + if [ "$key" = "python" ]; then + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + continue + fi + # String form? Try first. + if parse_poetry_keyval "$value"; then + if [ "$prefix" = "-" ]; then + pending_kind="poetry_keyval" + pending_name="$key" + pending_skeleton="" + pending_minus_version="$_poetry_keyval_version" + continue + fi + if [ "$prefix" = "+" ]; then + # Unmatched + (no preceding -) → new-dep addition → disqualify. + verdict="disqualified" + continue + fi + # Context line: nothing. + continue + fi + # Inline-table form? + if parse_poetry_inline "$value"; then + if [ "$prefix" = "-" ]; then + pending_kind="poetry_inline" + pending_name="$key" + pending_skeleton="$_poetry_inline_skeleton" + pending_minus_version="$_poetry_inline_version" + continue + fi + if [ "$prefix" = "+" ]; then + verdict="disqualified" + continue + fi + continue + fi + # Unrecognized poetry value form on a changed line → disqualify + # (git URL, path, editable, etc.). + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + ;; + build_system|other) + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + ;; + esac + continue + fi + + # PEP 508 string-in-array entry. + if parse_pep508_entry "$content"; then + # Positively-established array context required (Blocker 3). + in_array=false + case "$current_table" in + project_other) + [ "$current_key" = "dependencies" ] && in_array=true + ;; + project_optional_deps|dependency_groups) + [ -n "$current_key" ] && in_array=true + ;; + tool_uv) + case "$current_key" in + constraint-dependencies|override-dependencies) in_array=true ;; + esac + ;; + esac + if [ "$in_array" = "false" ]; then + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + continue + fi + if [ "$prefix" = "-" ]; then + pending_kind="pep508" + pending_name="$_pep_name" + pending_skeleton="${_pep_name}${_pep_extras}__VER__${_pep_marker}" + pending_minus_version="$_pep_version_spec" + continue + fi + if [ "$prefix" = "+" ]; then + # Unmatched + (no preceding -) → new-dep addition → disqualify. + verdict="disqualified" + continue + fi + continue + fi + + # Closing `]` of an array. On +/- this is reformatting/structural → disqualify. + # On a context line, the array we were tracking has closed → reset current_key + # so subsequent PEP 508-shaped lines do not inherit dependency-array context + # (any later entries need fresh context to re-establish in_array). + if [[ "$content" =~ ^[[:space:]]*\] ]]; then + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + current_key="" + continue + fi + + # Anything else changed in pyproject.toml → disqualify. + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + done <<< "$input" + + flush_file + + if [ "$MODE" = "deps" ]; then + if [ ${#out_deps_rows[@]} -gt 0 ]; then + printf '%s\n' "${out_deps_rows[@]}" | sort -t$'\t' -k1,1 -u + fi + else + if [ ${#out_cleared_paths[@]} -gt 0 ]; then + printf '%s\n' "${out_cleared_paths[@]}" | sort -u + fi + fi + ) + # --- END inline:scripts/pyproject-bump-extract.sh --- + # --- BEGIN inline:scripts/pr-body-to-deps.sh --- pr_body_to_deps() ( # pr-body-to-deps.sh — extract Dependabot dep bumps from a PR body, emit TSV. @@ -544,20 +1039,40 @@ jobs: PR_BODY=$(gh pr view "$PR_NUMBER" --repo "$GH_REPO" --json body --jq '.body // ""') # --- Extract dependencies via standalone script (Task 8) --- - DEPS_TSV=$(echo "$DIFF" | extract_deps) + # Layer 1: extract deps from supported file shapes. + EXTRACTED=$(echo "$DIFF" | extract_deps) + PYPROJECT_DEPS=$(printf '%s' "$DIFF" | pyproject_bump_extract --mode=deps 2>/dev/null || true) + DEPS_TSV=$(printf '%s\n%s\n' "$EXTRACTED" "$PYPROJECT_DEPS" | sed '/^$/d' | sort -u) # --- Compute touched paths and unsupported subset ONCE, up front. --- - # Both Layer 2 (PR-body fallback) and Layer 3 (fail-loud guard) consume - # these. Hoisted from inside Layer 2 to keep the two layers' inputs - # identical and to make the guard logic independent of DEPS_TSV being - # empty (fix for issue #62 — partial extraction silent-green). + # Layer 2: identify unsupported dependency-relevant paths. + # CLEARED_PYPROJECT is computed in the same hoist so Layer 2 and Layer 3 + # both see a consistent view of which paths the diff-aware helper proved + # are bump-only (fix for issue #66 — uv/poetry Dependabot PRs). TOUCHED_PATHS=$(echo "$DIFF" | diff_touches_lockfile 2>/dev/null || true) - UNSUPPORTED_PATHS=$(printf '%s\n' "$TOUCHED_PATHS" | classify_touched_paths 2>/dev/null || true) + BASE_UNSUPPORTED=$(printf '%s\n' "$TOUCHED_PATHS" | classify_touched_paths 2>/dev/null || true) + CLEARED_PYPROJECT=$(printf '%s' "$DIFF" | pyproject_bump_extract --mode=cleared-paths 2>/dev/null || true) + + # Subtract cleared paths from BOTH the unsupported set and the touched + # set used by Layer 3's zero-row guard. A pyproject.toml cleared with + # zero extracted deps (e.g., comment-only churn) represents proof that + # the scan was complete — there is nothing to scan. Without this + # subtraction, a comment-only pyproject PR would trip the zero-row guard. + UNSUPPORTED_PATHS="$BASE_UNSUPPORTED" + EFFECTIVE_TOUCHED="$TOUCHED_PATHS" + if [ -n "$(printf '%s\n' "$CLEARED_PYPROJECT" | sed '/^$/d')" ]; then + cleared_file=$(mktemp "${RUNNER_TEMP:-/tmp}/cleared-pyproject-paths.XXXXXX") + printf '%s\n' "$CLEARED_PYPROJECT" | sed '/^$/d' | sort -u > "$cleared_file" + UNSUPPORTED_PATHS=$(printf '%s\n' "$BASE_UNSUPPORTED" | sed '/^$/d' | grep -vFxf "$cleared_file" || true) + EFFECTIVE_TOUCHED=$(printf '%s\n' "$TOUCHED_PATHS" | sed '/^$/d' | grep -vFxf "$cleared_file" || true) + rm -f "$cleared_file" + fi # --- Layer 2: PR-body fallback (defense in depth for issue #52) --- - # Routing list unchanged — pyproject.toml and Pipfile remain pypi-routable - # here even though Layer 3 will still fire on them, since the guard - # reflects extract-deps parser support, not PR-body recovery support. + # pyproject.toml and Pipfile remain pypi-routable for PR-body fallback. + # Layer 3 still fires on Pipfile and on any pyproject.toml whose hunks + # pyproject_bump_extract did not clear; cleared pyproject.toml paths + # are subtracted from UNSUPPORTED_PATHS in the composition block above. if [ -z "$(echo "$DEPS_TSV" | sed '/^$/d')" ]; then ECO_HINT="" if echo "$TOUCHED_PATHS" | grep -qE '((^|/)(uv|poetry)\.lock$|requirements[^/]*\.txt$|pyproject\.toml$|Pipfile$)'; then @@ -583,9 +1098,9 @@ jobs: GUARD_TRIGGERED=true UNSUPPORTED_LIST=$(echo "$UNSUPPORTED_PATHS" | paste -sd, -) EXTRACTION_WARNING="⚠️ Diff touches dependency files the parser does not support (${UNSUPPORTED_LIST}). Manual review required; the cooldown/safety gate cannot scan these." - elif [ -z "$(echo "$DEPS_TSV" | sed '/^$/d')" ] && [ -n "$TOUCHED_PATHS" ]; then + elif [ -z "$(echo "$DEPS_TSV" | sed '/^$/d')" ] && [ -n "$EFFECTIVE_TOUCHED" ]; then GUARD_TRIGGERED=true - TOUCHED_LIST=$(echo "$TOUCHED_PATHS" | paste -sd, -) + TOUCHED_LIST=$(echo "$EFFECTIVE_TOUCHED" | paste -sd, -) EXTRACTION_WARNING="⚠️ Parser could not extract dependencies from this diff (touched: ${TOUCHED_LIST}). Manual review required before merge." fi diff --git a/.github/workflows/dependency-safety.yml b/.github/workflows/dependency-safety.yml index dae5a6f..8f0e82f 100644 --- a/.github/workflows/dependency-safety.yml +++ b/.github/workflows/dependency-safety.yml @@ -293,6 +293,13 @@ jobs: # unsupported and printed to stdout. Layer 2 (PR-body fallback) may still # recover deps from some of these for the scan loop, but the guard fires # because the diff parser cannot prove the scan was complete. + # + # pyproject.toml is path-level unsupported here and may be cleared by + # scripts/pyproject-bump-extract.sh at the workflow composition layer + # when its hunks are proven to be bump-only. This script remains + # path-only and intentionally conservative; the final unsupported set + # in the workflow is produced by composition, not by this classifier + # alone. set -euo pipefail @@ -315,6 +322,494 @@ jobs: ) # --- END inline:scripts/classify-touched-paths.sh --- + # --- BEGIN inline:scripts/pyproject-bump-extract.sh --- + pyproject_bump_extract() ( + # pyproject-bump-extract.sh — diff-aware extractor for pyproject.toml bump-only edits. + # + # Owns all pyproject.toml diff semantics for the dependency-cooldown/safety + # workflows. Recognizes the narrow set of bump shapes Dependabot emits for + # uv/poetry ecosystems and emits either extracted dep rows (mode=deps) or + # the paths it proved are bump-only (mode=cleared-paths). Files with any + # unparseable changed line (build-system edits, new-dep additions, marker + # changes, etc.) are disqualified and left unsupported so the existing + # fail-loud guard fires. + # + # Input: unified diff on stdin + # Flag: --mode=deps OR --mode=cleared-paths (exactly one, required) + # Output (deps): TSV \t\tpypi, sorted by name, deduped + # Output (cleared-paths): newline-delimited pyproject.toml paths, sorted, deduped + # Exit: 0 on success (possibly zero rows / zero paths) + # 2 on malformed input, missing --mode, unknown mode, or repeated --mode + # + # Bash 3.2 compatible: no `declare -A`, no `mapfile`/`readarray`. Dedup uses a + # newline-delimited string sentinel, matching scripts/extract-deps.sh. + # + # See docs/superpowers/specs/2026-05-23-pyproject-toml-parser-design.md. + + set -euo pipefail + + MODE="" + for arg in "$@"; do + case "$arg" in + --mode=deps|--mode=cleared-paths) + if [ -n "$MODE" ]; then + echo "pyproject-bump-extract.sh: --mode specified more than once" >&2 + exit 2 + fi + MODE="${arg#--mode=}" + ;; + *) + echo "pyproject-bump-extract.sh: unknown argument: $arg" >&2 + exit 2 + ;; + esac + done + + if [ -z "$MODE" ]; then + echo "pyproject-bump-extract.sh: --mode=deps or --mode=cleared-paths required" >&2 + exit 2 + fi + + input=$(cat) + + # Empty input → exit 0 with no output (matches extract-deps.sh). + if [ -z "$input" ]; then + exit 0 + fi + + # Malformed input detection: here-string (not pipeline) to avoid SIGPIPE under + # pipefail on large valid diffs (issue #50 pattern). + if ! grep -qE '^(\+\+\+|---|@@|diff --git)' <<< "$input"; then + echo "pyproject-bump-extract.sh: input is not a unified diff" >&2 + exit 2 + fi + + # --- Parser state --- + # Per-file (reset on each diff --git boundary): + current_path="" + current_basename="" + current_table="" # "" | "project_other" | "project_optional_deps" | "dependency_groups" + # | "tool_uv" | "poetry_main" | "poetry_group" | "poetry_dev" + # | "build_system" | "other" + current_key="" # array-opening key for tables where keys are dep arrays + verdict="clean" + file_rows=$'\n' + + # Global: + out_deps_rows=() + out_cleared_paths=() + seen=$'\n' # dedup sentinel "\npypi:\n" + + # Single pending tracker. Lifetime = exactly one line. If pending is set, + # the IMMEDIATELY NEXT line MUST be the matching + (same kind, same name, + # same skeleton). Anything else (other -, unmatched +, context, comment, + # header, hunk boundary, file boundary, EOF) disqualifies. + pending_kind="" # "" | "pep508" | "poetry_keyval" | "poetry_inline" + pending_name="" + pending_skeleton="" # entry with version-spec field substituted by sentinel + pending_minus_version="" + + # --- Helpers --- + + # extract_target_version "$spec" — §3.3.1. Prints target on stdout, returns 1 on disqualify. + extract_target_version() { + local spec="$1" stripped + spec="${spec#"${spec%%[![:space:]]*}"}" + spec="${spec%"${spec##*[![:space:]]}"}" + case "$spec" in *,*) return 1 ;; esac + case "$spec" in + \^*) stripped="${spec#\^}" ;; + \~=*) stripped="${spec#~=}" ;; + \~*) stripped="${spec#~}" ;; + \>=*) stripped="${spec#>=}" ;; + ==*) stripped="${spec#==}" ;; + \<*|\<=*|\>*|!=*) return 1 ;; + *) stripped="$spec" ;; + esac + stripped="${stripped#"${stripped%%[![:space:]]*}"}" + stripped="${stripped%"${stripped##*[![:space:]]}"}" + if [[ "$stripped" =~ ^[0-9]+(\.[0-9]+)*((a|b|rc|alpha|beta)[0-9]*)?(\.post[0-9]+)?(\.dev[0-9]+)?$ ]]; then + printf '%s' "$stripped" + return 0 + fi + return 1 + } + + # parse_pep508_entry "$content" — captures name/extras/version_spec/marker. + # Sets _pep_name, _pep_extras (with brackets, or empty), _pep_version_spec, + # _pep_marker (with leading ';', or empty). Returns 0/1. + parse_pep508_entry() { + local content="$1" trimmed + _pep_name=""; _pep_extras=""; _pep_version_spec=""; _pep_marker="" + if [[ "$content" =~ ^[[:space:]]*\"([A-Za-z][A-Za-z0-9_.-]*)(\[[^]]*\])?([^\";]*)(;.*)?\"[[:space:]]*,?[[:space:]]*$ ]]; then + _pep_name="${BASH_REMATCH[1]}" + _pep_extras="${BASH_REMATCH[2]}" + _pep_version_spec="${BASH_REMATCH[3]}" + _pep_marker="${BASH_REMATCH[4]}" + # Validate: non-empty version_spec must start with a PEP 508 operator. + # Bare versions (e.g., "foo 1.0") are malformed PEP 508 and must not parse. + trimmed="${_pep_version_spec#"${_pep_version_spec%%[![:space:]]*}"}" + if [ -n "$trimmed" ]; then + case "$trimmed" in + \^*|\~=*|\~*|\>=*|==*|\<=*|\<*|\>*|!=*) ;; + *) return 1 ;; + esac + fi + return 0 + fi + return 1 + } + + # parse_poetry_keyval "$value" — for "spec" form. Sets _poetry_keyval_version. + parse_poetry_keyval() { + local value="$1" + _poetry_keyval_version="" + if [[ "$value" =~ ^[[:space:]]*\"([^\"]*)\"[[:space:]]*$ ]]; then + _poetry_keyval_version="${BASH_REMATCH[1]}" + return 0 + fi + return 1 + } + + # parse_poetry_inline "$value" — for { version = "spec", ... } form. + # Sets _poetry_inline_version and _poetry_inline_skeleton (version VALUE + # substituted, surrounding whitespace preserved). Requires `version` to be + # preceded by start-of-string, comma, or whitespace so `subversion` does + # not match (Bug 2). Preserves original whitespace around `=` so + # reformatting that changes spacing is caught (Bug 4). + parse_poetry_inline() { + local value="$1" inner + _poetry_inline_version=""; _poetry_inline_skeleton="" + if [[ "$value" =~ ^[[:space:]]*\{(.*)\}[[:space:]]*$ ]]; then + inner="${BASH_REMATCH[1]}" + if [[ "$inner" =~ (^|[,[:space:]])version[[:space:]]*=[[:space:]]*\"([^\"]*)\" ]]; then + _poetry_inline_version="${BASH_REMATCH[2]}" + _poetry_inline_skeleton=$(printf '%s' "$inner" | sed -E 's/(^|[,[:space:]])(version[[:space:]]*=[[:space:]]*)"[^"]*"/\1\2"__VER__"/') + return 0 + fi + fi + return 1 + } + + # extract_operator "$spec" — returns the operator portion on stdout. + # "^", "~=", "~", ">=", "==" → printed verbatim; bare version → empty string. + # Unsupported operators (<, <=, >, !=) → return 1 (caller treats as disqualify). + extract_operator() { + local spec="$1" trimmed + trimmed="${spec#"${spec%%[![:space:]]*}"}" + case "$trimmed" in + \^*) printf '%s' '^' ;; + \~=*) printf '%s' '~=' ;; + \~*) printf '%s' '~' ;; + \>=*) printf '%s' '>=' ;; + ==*) printf '%s' '==' ;; + \<=*|\<*|\>*|!=*) return 1 ;; + *) printf '%s' '' ;; + esac + return 0 + } + + emit_bump() { + local name="$1" plus_spec="$2" minus_spec="$3" target plus_op minus_op + if [ "$plus_spec" = "$minus_spec" ]; then verdict="disqualified"; return; fi + plus_op=$(extract_operator "$plus_spec") || { verdict="disqualified"; return; } + minus_op=$(extract_operator "$minus_spec") || { verdict="disqualified"; return; } + if [ "$plus_op" != "$minus_op" ]; then verdict="disqualified"; return; fi + target=$(extract_target_version "$plus_spec") || { verdict="disqualified"; return; } + extract_target_version "$minus_spec" >/dev/null || { verdict="disqualified"; return; } + file_rows="${file_rows}${name} ${target} pypi"$'\n' + } + + clear_pending() { + pending_kind="" + pending_name="" + pending_skeleton="" + pending_minus_version="" + } + + flush_pending_as_disqualified() { + if [ -n "$pending_kind" ]; then + verdict="disqualified" + clear_pending + fi + } + + flush_file() { + flush_pending_as_disqualified + if [ -z "$current_basename" ] || [ "$current_basename" != "pyproject.toml" ]; then + return + fi + if [ "$verdict" = "clean" ]; then + while IFS= read -r row; do + [ -z "$row" ] && continue + local name="${row%% *}" + local key="pypi:$name" + case "$seen" in *$'\n'"$key"$'\n'*) continue ;; esac + seen="${seen}${key}"$'\n' + out_deps_rows+=("$row") + done <<< "$file_rows" + out_cleared_paths+=("$current_path") + fi + } + + reset_file_state() { + current_table="" + current_key="" + verdict="clean" + file_rows=$'\n' + clear_pending + } + + # --- Main parse loop --- + while IFS= read -r line; do + line="${line%$'\r'}" + + # File boundary. + if [[ "$line" =~ ^diff[[:space:]]--git[[:space:]]a/([^[:space:]]+)[[:space:]]b/([^[:space:]]+) ]]; then + flush_file + current_path="${BASH_REMATCH[2]}" + current_basename="${current_path##*/}" + reset_file_state + continue + fi + + [[ "$line" == +++* ]] && continue + [[ "$line" == ---* ]] && continue + + if [[ "$line" =~ ^@@ ]]; then + flush_pending_as_disqualified + current_table="" + current_key="" + continue + fi + + [ "$current_basename" != "pyproject.toml" ] && continue + [ "$verdict" = "disqualified" ] && continue + + prefix="${line:0:1}" + content="${line:1}" + + # ---------------- Centralized pending consumption ---------------- + # If pending is set, this line MUST be the matching + (same kind/name/skeleton) + # or the file is disqualified. No other line type may consume or pass through + # pending state. + if [ -n "$pending_kind" ]; then + consumed=false + if [ "$prefix" = "+" ]; then + case "$pending_kind" in + pep508) + if parse_pep508_entry "$content"; then + plus_skeleton="${_pep_name}${_pep_extras}__VER__${_pep_marker}" + if [ "$_pep_name" = "$pending_name" ] && [ "$plus_skeleton" = "$pending_skeleton" ]; then + emit_bump "$_pep_name" "$_pep_version_spec" "$pending_minus_version" + consumed=true + fi + fi + ;; + poetry_keyval) + if [[ "$content" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_-]*)[[:space:]]*=[[:space:]]*(.*)$ ]]; then + plus_key="${BASH_REMATCH[1]}" + plus_value="${BASH_REMATCH[2]}" + if [ "$plus_key" = "$pending_name" ] && parse_poetry_keyval "$plus_value"; then + emit_bump "$plus_key" "$_poetry_keyval_version" "$pending_minus_version" + consumed=true + fi + fi + ;; + poetry_inline) + if [[ "$content" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_-]*)[[:space:]]*=[[:space:]]*(.*)$ ]]; then + plus_key="${BASH_REMATCH[1]}" + plus_value="${BASH_REMATCH[2]}" + if [ "$plus_key" = "$pending_name" ] && parse_poetry_inline "$plus_value"; then + if [ "$_poetry_inline_skeleton" = "$pending_skeleton" ]; then + emit_bump "$plus_key" "$_poetry_inline_version" "$pending_minus_version" + consumed=true + fi + fi + fi + ;; + esac + fi + clear_pending + if [ "$consumed" = "false" ]; then + verdict="disqualified" + fi + continue + fi + # ---------------- End pending consumption ---------------- + + # Comment / whitespace allowance anywhere (§3.4 rule 2). + if [[ "$content" =~ ^[[:space:]]*# ]]; then continue; fi + if [[ "$content" =~ ^[[:space:]]*$ ]]; then continue; fi + + # Table header detection. + if [[ "$content" =~ ^[[:space:]]*\[([^]]+)\] ]]; then + header="${BASH_REMATCH[1]}" + case "$header" in + project) current_table="project_other"; current_key="" ;; + project.optional-dependencies) current_table="project_optional_deps"; current_key="" ;; + dependency-groups) current_table="dependency_groups"; current_key="" ;; + tool.uv) current_table="tool_uv"; current_key="" ;; + tool.poetry.dependencies) current_table="poetry_main"; current_key="" ;; + tool.poetry.dev-dependencies) current_table="poetry_dev"; current_key="" ;; + build-system) current_table="build_system"; current_key="" ;; + *) + case "$header" in + tool.poetry.group.*.dependencies) current_table="poetry_group"; current_key="" ;; + *) current_table="other"; current_key="" ;; + esac + ;; + esac + # Changed table header = structural change → disqualify. + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + continue + fi + + # Key = ... line — per-table routing. + if [[ "$content" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_-]*)[[:space:]]*=[[:space:]]*(.*)$ ]]; then + key="${BASH_REMATCH[1]}" + value="${BASH_REMATCH[2]}" + case "$current_table" in + project_other) + if [ "$key" = "dependencies" ]; then + # `dependencies = [` on +/- = structural change. + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; continue; fi + current_key="dependencies" + else + # Any other [project] key on +/- (name, version, description, ...) disqualifies. + current_key="" + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + fi + ;; + project_optional_deps|dependency_groups) + # Adding/renaming an extras key or dep group is structural. + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; continue; fi + current_key="$key" + ;; + tool_uv) + case "$key" in + constraint-dependencies|override-dependencies) + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; continue; fi + current_key="$key" + ;; + *) + current_key="" + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + ;; + esac + ;; + poetry_main|poetry_group|poetry_dev) + if [ "$key" = "python" ]; then + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + continue + fi + # String form? Try first. + if parse_poetry_keyval "$value"; then + if [ "$prefix" = "-" ]; then + pending_kind="poetry_keyval" + pending_name="$key" + pending_skeleton="" + pending_minus_version="$_poetry_keyval_version" + continue + fi + if [ "$prefix" = "+" ]; then + # Unmatched + (no preceding -) → new-dep addition → disqualify. + verdict="disqualified" + continue + fi + # Context line: nothing. + continue + fi + # Inline-table form? + if parse_poetry_inline "$value"; then + if [ "$prefix" = "-" ]; then + pending_kind="poetry_inline" + pending_name="$key" + pending_skeleton="$_poetry_inline_skeleton" + pending_minus_version="$_poetry_inline_version" + continue + fi + if [ "$prefix" = "+" ]; then + verdict="disqualified" + continue + fi + continue + fi + # Unrecognized poetry value form on a changed line → disqualify + # (git URL, path, editable, etc.). + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + ;; + build_system|other) + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + ;; + esac + continue + fi + + # PEP 508 string-in-array entry. + if parse_pep508_entry "$content"; then + # Positively-established array context required (Blocker 3). + in_array=false + case "$current_table" in + project_other) + [ "$current_key" = "dependencies" ] && in_array=true + ;; + project_optional_deps|dependency_groups) + [ -n "$current_key" ] && in_array=true + ;; + tool_uv) + case "$current_key" in + constraint-dependencies|override-dependencies) in_array=true ;; + esac + ;; + esac + if [ "$in_array" = "false" ]; then + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + continue + fi + if [ "$prefix" = "-" ]; then + pending_kind="pep508" + pending_name="$_pep_name" + pending_skeleton="${_pep_name}${_pep_extras}__VER__${_pep_marker}" + pending_minus_version="$_pep_version_spec" + continue + fi + if [ "$prefix" = "+" ]; then + # Unmatched + (no preceding -) → new-dep addition → disqualify. + verdict="disqualified" + continue + fi + continue + fi + + # Closing `]` of an array. On +/- this is reformatting/structural → disqualify. + # On a context line, the array we were tracking has closed → reset current_key + # so subsequent PEP 508-shaped lines do not inherit dependency-array context + # (any later entries need fresh context to re-establish in_array). + if [[ "$content" =~ ^[[:space:]]*\] ]]; then + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + current_key="" + continue + fi + + # Anything else changed in pyproject.toml → disqualify. + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + done <<< "$input" + + flush_file + + if [ "$MODE" = "deps" ]; then + if [ ${#out_deps_rows[@]} -gt 0 ]; then + printf '%s\n' "${out_deps_rows[@]}" | sort -t$'\t' -k1,1 -u + fi + else + if [ ${#out_cleared_paths[@]} -gt 0 ]; then + printf '%s\n' "${out_cleared_paths[@]}" | sort -u + fi + fi + ) + # --- END inline:scripts/pyproject-bump-extract.sh --- + # --- BEGIN inline:scripts/pr-body-to-deps.sh --- pr_body_to_deps() ( # pr-body-to-deps.sh — extract Dependabot dep bumps from a PR body, emit TSV. @@ -679,20 +1174,40 @@ jobs: PR_BODY=$(gh pr view "$PR_NUMBER" --repo "$GH_REPO" --json body --jq '.body // ""') # --- Extract dependencies via standalone script (Task 8) --- - DEPS_TSV=$(echo "$DIFF" | extract_deps) + # Layer 1: extract deps from supported file shapes. + EXTRACTED=$(echo "$DIFF" | extract_deps) + PYPROJECT_DEPS=$(printf '%s' "$DIFF" | pyproject_bump_extract --mode=deps 2>/dev/null || true) + DEPS_TSV=$(printf '%s\n%s\n' "$EXTRACTED" "$PYPROJECT_DEPS" | sed '/^$/d' | sort -u) # --- Compute touched paths and unsupported subset ONCE, up front. --- - # Both Layer 2 (PR-body fallback) and Layer 3 (fail-loud guard) consume - # these. Hoisted from inside Layer 2 to keep the two layers' inputs - # identical and to make the guard logic independent of DEPS_TSV being - # empty (fix for issue #62 — partial extraction silent-green). + # Layer 2: identify unsupported dependency-relevant paths. + # CLEARED_PYPROJECT is computed in the same hoist so Layer 2 and Layer 3 + # both see a consistent view of which paths the diff-aware helper proved + # are bump-only (fix for issue #66 — uv/poetry Dependabot PRs). TOUCHED_PATHS=$(echo "$DIFF" | diff_touches_lockfile 2>/dev/null || true) - UNSUPPORTED_PATHS=$(printf '%s\n' "$TOUCHED_PATHS" | classify_touched_paths 2>/dev/null || true) + BASE_UNSUPPORTED=$(printf '%s\n' "$TOUCHED_PATHS" | classify_touched_paths 2>/dev/null || true) + CLEARED_PYPROJECT=$(printf '%s' "$DIFF" | pyproject_bump_extract --mode=cleared-paths 2>/dev/null || true) + + # Subtract cleared paths from BOTH the unsupported set and the touched + # set used by Layer 3's zero-row guard. A pyproject.toml cleared with + # zero extracted deps (e.g., comment-only churn) represents proof that + # the scan was complete — there is nothing to scan. Without this + # subtraction, a comment-only pyproject PR would trip the zero-row guard. + UNSUPPORTED_PATHS="$BASE_UNSUPPORTED" + EFFECTIVE_TOUCHED="$TOUCHED_PATHS" + if [ -n "$(printf '%s\n' "$CLEARED_PYPROJECT" | sed '/^$/d')" ]; then + cleared_file=$(mktemp "${RUNNER_TEMP:-/tmp}/cleared-pyproject-paths.XXXXXX") + printf '%s\n' "$CLEARED_PYPROJECT" | sed '/^$/d' | sort -u > "$cleared_file" + UNSUPPORTED_PATHS=$(printf '%s\n' "$BASE_UNSUPPORTED" | sed '/^$/d' | grep -vFxf "$cleared_file" || true) + EFFECTIVE_TOUCHED=$(printf '%s\n' "$TOUCHED_PATHS" | sed '/^$/d' | grep -vFxf "$cleared_file" || true) + rm -f "$cleared_file" + fi # --- Layer 2: PR-body fallback (defense in depth for issue #52) --- - # Routing list unchanged — pyproject.toml and Pipfile remain pypi-routable - # here even though Layer 3 will still fire on them, since the guard - # reflects extract-deps parser support, not PR-body recovery support. + # pyproject.toml and Pipfile remain pypi-routable for PR-body fallback. + # Layer 3 still fires on Pipfile and on any pyproject.toml whose hunks + # pyproject_bump_extract did not clear; cleared pyproject.toml paths + # are subtracted from UNSUPPORTED_PATHS in the composition block above. if [ -z "$(echo "$DEPS_TSV" | sed '/^$/d')" ]; then ECO_HINT="" if echo "$TOUCHED_PATHS" | grep -qE '((^|/)(uv|poetry)\.lock$|requirements[^/]*\.txt$|pyproject\.toml$|Pipfile$)'; then @@ -718,9 +1233,9 @@ jobs: GUARD_TRIGGERED=true UNSUPPORTED_LIST=$(echo "$UNSUPPORTED_PATHS" | paste -sd, -) EXTRACTION_WARNING="⚠️ Diff touches dependency files the parser does not support (${UNSUPPORTED_LIST}). Manual review required; the cooldown/safety gate cannot scan these." - elif [ -z "$(echo "$DEPS_TSV" | sed '/^$/d')" ] && [ -n "$TOUCHED_PATHS" ]; then + elif [ -z "$(echo "$DEPS_TSV" | sed '/^$/d')" ] && [ -n "$EFFECTIVE_TOUCHED" ]; then GUARD_TRIGGERED=true - TOUCHED_LIST=$(echo "$TOUCHED_PATHS" | paste -sd, -) + TOUCHED_LIST=$(echo "$EFFECTIVE_TOUCHED" | paste -sd, -) EXTRACTION_WARNING="⚠️ Parser could not extract dependencies from this diff (touched: ${TOUCHED_LIST}). Manual review required before merge." fi diff --git a/scripts/check-inline-sync.sh b/scripts/check-inline-sync.sh index 59ad179..5c9993c 100755 --- a/scripts/check-inline-sync.sh +++ b/scripts/check-inline-sync.sh @@ -23,6 +23,8 @@ INLINE_PAIRS=( ".github/workflows/dependency-safety.yml:scripts/safety-verdict.sh" ".github/workflows/dependency-safety.yml:scripts/classify-touched-paths.sh" ".github/workflows/tag-release.yml:scripts/bump-version-files.sh" + ".github/workflows/dependency-cooldown.yml:scripts/pyproject-bump-extract.sh" + ".github/workflows/dependency-safety.yml:scripts/pyproject-bump-extract.sh" ) YAML_INDENT=" " # exactly 10 spaces — matches the `run: |` indent diff --git a/scripts/classify-touched-paths.sh b/scripts/classify-touched-paths.sh index 4678f60..b9b0872 100755 --- a/scripts/classify-touched-paths.sh +++ b/scripts/classify-touched-paths.sh @@ -29,6 +29,13 @@ # unsupported and printed to stdout. Layer 2 (PR-body fallback) may still # recover deps from some of these for the scan loop, but the guard fires # because the diff parser cannot prove the scan was complete. +# +# pyproject.toml is path-level unsupported here and may be cleared by +# scripts/pyproject-bump-extract.sh at the workflow composition layer +# when its hunks are proven to be bump-only. This script remains +# path-only and intentionally conservative; the final unsupported set +# in the workflow is produced by composition, not by this classifier +# alone. set -euo pipefail diff --git a/scripts/pyproject-bump-extract.sh b/scripts/pyproject-bump-extract.sh new file mode 100755 index 0000000..7b10f70 --- /dev/null +++ b/scripts/pyproject-bump-extract.sh @@ -0,0 +1,484 @@ +#!/usr/bin/env bash +# pyproject-bump-extract.sh — diff-aware extractor for pyproject.toml bump-only edits. +# +# Owns all pyproject.toml diff semantics for the dependency-cooldown/safety +# workflows. Recognizes the narrow set of bump shapes Dependabot emits for +# uv/poetry ecosystems and emits either extracted dep rows (mode=deps) or +# the paths it proved are bump-only (mode=cleared-paths). Files with any +# unparseable changed line (build-system edits, new-dep additions, marker +# changes, etc.) are disqualified and left unsupported so the existing +# fail-loud guard fires. +# +# Input: unified diff on stdin +# Flag: --mode=deps OR --mode=cleared-paths (exactly one, required) +# Output (deps): TSV \t\tpypi, sorted by name, deduped +# Output (cleared-paths): newline-delimited pyproject.toml paths, sorted, deduped +# Exit: 0 on success (possibly zero rows / zero paths) +# 2 on malformed input, missing --mode, unknown mode, or repeated --mode +# +# Bash 3.2 compatible: no `declare -A`, no `mapfile`/`readarray`. Dedup uses a +# newline-delimited string sentinel, matching scripts/extract-deps.sh. +# +# See docs/superpowers/specs/2026-05-23-pyproject-toml-parser-design.md. + +set -euo pipefail + +MODE="" +for arg in "$@"; do + case "$arg" in + --mode=deps|--mode=cleared-paths) + if [ -n "$MODE" ]; then + echo "pyproject-bump-extract.sh: --mode specified more than once" >&2 + exit 2 + fi + MODE="${arg#--mode=}" + ;; + *) + echo "pyproject-bump-extract.sh: unknown argument: $arg" >&2 + exit 2 + ;; + esac +done + +if [ -z "$MODE" ]; then + echo "pyproject-bump-extract.sh: --mode=deps or --mode=cleared-paths required" >&2 + exit 2 +fi + +input=$(cat) + +# Empty input → exit 0 with no output (matches extract-deps.sh). +if [ -z "$input" ]; then + exit 0 +fi + +# Malformed input detection: here-string (not pipeline) to avoid SIGPIPE under +# pipefail on large valid diffs (issue #50 pattern). +if ! grep -qE '^(\+\+\+|---|@@|diff --git)' <<< "$input"; then + echo "pyproject-bump-extract.sh: input is not a unified diff" >&2 + exit 2 +fi + +# --- Parser state --- +# Per-file (reset on each diff --git boundary): +current_path="" +current_basename="" +current_table="" # "" | "project_other" | "project_optional_deps" | "dependency_groups" + # | "tool_uv" | "poetry_main" | "poetry_group" | "poetry_dev" + # | "build_system" | "other" +current_key="" # array-opening key for tables where keys are dep arrays +verdict="clean" +file_rows=$'\n' + +# Global: +out_deps_rows=() +out_cleared_paths=() +seen=$'\n' # dedup sentinel "\npypi:\n" + +# Single pending tracker. Lifetime = exactly one line. If pending is set, +# the IMMEDIATELY NEXT line MUST be the matching + (same kind, same name, +# same skeleton). Anything else (other -, unmatched +, context, comment, +# header, hunk boundary, file boundary, EOF) disqualifies. +pending_kind="" # "" | "pep508" | "poetry_keyval" | "poetry_inline" +pending_name="" +pending_skeleton="" # entry with version-spec field substituted by sentinel +pending_minus_version="" + +# --- Helpers --- + +# extract_target_version "$spec" — §3.3.1. Prints target on stdout, returns 1 on disqualify. +extract_target_version() { + local spec="$1" stripped + spec="${spec#"${spec%%[![:space:]]*}"}" + spec="${spec%"${spec##*[![:space:]]}"}" + case "$spec" in *,*) return 1 ;; esac + case "$spec" in + \^*) stripped="${spec#\^}" ;; + \~=*) stripped="${spec#~=}" ;; + \~*) stripped="${spec#~}" ;; + \>=*) stripped="${spec#>=}" ;; + ==*) stripped="${spec#==}" ;; + \<*|\<=*|\>*|!=*) return 1 ;; + *) stripped="$spec" ;; + esac + stripped="${stripped#"${stripped%%[![:space:]]*}"}" + stripped="${stripped%"${stripped##*[![:space:]]}"}" + if [[ "$stripped" =~ ^[0-9]+(\.[0-9]+)*((a|b|rc|alpha|beta)[0-9]*)?(\.post[0-9]+)?(\.dev[0-9]+)?$ ]]; then + printf '%s' "$stripped" + return 0 + fi + return 1 +} + +# parse_pep508_entry "$content" — captures name/extras/version_spec/marker. +# Sets _pep_name, _pep_extras (with brackets, or empty), _pep_version_spec, +# _pep_marker (with leading ';', or empty). Returns 0/1. +parse_pep508_entry() { + local content="$1" trimmed + _pep_name=""; _pep_extras=""; _pep_version_spec=""; _pep_marker="" + if [[ "$content" =~ ^[[:space:]]*\"([A-Za-z][A-Za-z0-9_.-]*)(\[[^]]*\])?([^\";]*)(;.*)?\"[[:space:]]*,?[[:space:]]*$ ]]; then + _pep_name="${BASH_REMATCH[1]}" + _pep_extras="${BASH_REMATCH[2]}" + _pep_version_spec="${BASH_REMATCH[3]}" + _pep_marker="${BASH_REMATCH[4]}" + # Validate: non-empty version_spec must start with a PEP 508 operator. + # Bare versions (e.g., "foo 1.0") are malformed PEP 508 and must not parse. + trimmed="${_pep_version_spec#"${_pep_version_spec%%[![:space:]]*}"}" + if [ -n "$trimmed" ]; then + case "$trimmed" in + \^*|\~=*|\~*|\>=*|==*|\<=*|\<*|\>*|!=*) ;; + *) return 1 ;; + esac + fi + return 0 + fi + return 1 +} + +# parse_poetry_keyval "$value" — for "spec" form. Sets _poetry_keyval_version. +parse_poetry_keyval() { + local value="$1" + _poetry_keyval_version="" + if [[ "$value" =~ ^[[:space:]]*\"([^\"]*)\"[[:space:]]*$ ]]; then + _poetry_keyval_version="${BASH_REMATCH[1]}" + return 0 + fi + return 1 +} + +# parse_poetry_inline "$value" — for { version = "spec", ... } form. +# Sets _poetry_inline_version and _poetry_inline_skeleton (version VALUE +# substituted, surrounding whitespace preserved). Requires `version` to be +# preceded by start-of-string, comma, or whitespace so `subversion` does +# not match (Bug 2). Preserves original whitespace around `=` so +# reformatting that changes spacing is caught (Bug 4). +parse_poetry_inline() { + local value="$1" inner + _poetry_inline_version=""; _poetry_inline_skeleton="" + if [[ "$value" =~ ^[[:space:]]*\{(.*)\}[[:space:]]*$ ]]; then + inner="${BASH_REMATCH[1]}" + if [[ "$inner" =~ (^|[,[:space:]])version[[:space:]]*=[[:space:]]*\"([^\"]*)\" ]]; then + _poetry_inline_version="${BASH_REMATCH[2]}" + _poetry_inline_skeleton=$(printf '%s' "$inner" | sed -E 's/(^|[,[:space:]])(version[[:space:]]*=[[:space:]]*)"[^"]*"/\1\2"__VER__"/') + return 0 + fi + fi + return 1 +} + +# extract_operator "$spec" — returns the operator portion on stdout. +# "^", "~=", "~", ">=", "==" → printed verbatim; bare version → empty string. +# Unsupported operators (<, <=, >, !=) → return 1 (caller treats as disqualify). +extract_operator() { + local spec="$1" trimmed + trimmed="${spec#"${spec%%[![:space:]]*}"}" + case "$trimmed" in + \^*) printf '%s' '^' ;; + \~=*) printf '%s' '~=' ;; + \~*) printf '%s' '~' ;; + \>=*) printf '%s' '>=' ;; + ==*) printf '%s' '==' ;; + \<=*|\<*|\>*|!=*) return 1 ;; + *) printf '%s' '' ;; + esac + return 0 +} + +emit_bump() { + local name="$1" plus_spec="$2" minus_spec="$3" target plus_op minus_op + if [ "$plus_spec" = "$minus_spec" ]; then verdict="disqualified"; return; fi + plus_op=$(extract_operator "$plus_spec") || { verdict="disqualified"; return; } + minus_op=$(extract_operator "$minus_spec") || { verdict="disqualified"; return; } + if [ "$plus_op" != "$minus_op" ]; then verdict="disqualified"; return; fi + target=$(extract_target_version "$plus_spec") || { verdict="disqualified"; return; } + extract_target_version "$minus_spec" >/dev/null || { verdict="disqualified"; return; } + file_rows="${file_rows}${name} ${target} pypi"$'\n' +} + +clear_pending() { + pending_kind="" + pending_name="" + pending_skeleton="" + pending_minus_version="" +} + +flush_pending_as_disqualified() { + if [ -n "$pending_kind" ]; then + verdict="disqualified" + clear_pending + fi +} + +flush_file() { + flush_pending_as_disqualified + if [ -z "$current_basename" ] || [ "$current_basename" != "pyproject.toml" ]; then + return + fi + if [ "$verdict" = "clean" ]; then + while IFS= read -r row; do + [ -z "$row" ] && continue + local name="${row%% *}" + local key="pypi:$name" + case "$seen" in *$'\n'"$key"$'\n'*) continue ;; esac + seen="${seen}${key}"$'\n' + out_deps_rows+=("$row") + done <<< "$file_rows" + out_cleared_paths+=("$current_path") + fi +} + +reset_file_state() { + current_table="" + current_key="" + verdict="clean" + file_rows=$'\n' + clear_pending +} + +# --- Main parse loop --- +while IFS= read -r line; do + line="${line%$'\r'}" + + # File boundary. + if [[ "$line" =~ ^diff[[:space:]]--git[[:space:]]a/([^[:space:]]+)[[:space:]]b/([^[:space:]]+) ]]; then + flush_file + current_path="${BASH_REMATCH[2]}" + current_basename="${current_path##*/}" + reset_file_state + continue + fi + + [[ "$line" == +++* ]] && continue + [[ "$line" == ---* ]] && continue + + if [[ "$line" =~ ^@@ ]]; then + flush_pending_as_disqualified + current_table="" + current_key="" + continue + fi + + [ "$current_basename" != "pyproject.toml" ] && continue + [ "$verdict" = "disqualified" ] && continue + + prefix="${line:0:1}" + content="${line:1}" + + # ---------------- Centralized pending consumption ---------------- + # If pending is set, this line MUST be the matching + (same kind/name/skeleton) + # or the file is disqualified. No other line type may consume or pass through + # pending state. + if [ -n "$pending_kind" ]; then + consumed=false + if [ "$prefix" = "+" ]; then + case "$pending_kind" in + pep508) + if parse_pep508_entry "$content"; then + plus_skeleton="${_pep_name}${_pep_extras}__VER__${_pep_marker}" + if [ "$_pep_name" = "$pending_name" ] && [ "$plus_skeleton" = "$pending_skeleton" ]; then + emit_bump "$_pep_name" "$_pep_version_spec" "$pending_minus_version" + consumed=true + fi + fi + ;; + poetry_keyval) + if [[ "$content" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_-]*)[[:space:]]*=[[:space:]]*(.*)$ ]]; then + plus_key="${BASH_REMATCH[1]}" + plus_value="${BASH_REMATCH[2]}" + if [ "$plus_key" = "$pending_name" ] && parse_poetry_keyval "$plus_value"; then + emit_bump "$plus_key" "$_poetry_keyval_version" "$pending_minus_version" + consumed=true + fi + fi + ;; + poetry_inline) + if [[ "$content" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_-]*)[[:space:]]*=[[:space:]]*(.*)$ ]]; then + plus_key="${BASH_REMATCH[1]}" + plus_value="${BASH_REMATCH[2]}" + if [ "$plus_key" = "$pending_name" ] && parse_poetry_inline "$plus_value"; then + if [ "$_poetry_inline_skeleton" = "$pending_skeleton" ]; then + emit_bump "$plus_key" "$_poetry_inline_version" "$pending_minus_version" + consumed=true + fi + fi + fi + ;; + esac + fi + clear_pending + if [ "$consumed" = "false" ]; then + verdict="disqualified" + fi + continue + fi + # ---------------- End pending consumption ---------------- + + # Comment / whitespace allowance anywhere (§3.4 rule 2). + if [[ "$content" =~ ^[[:space:]]*# ]]; then continue; fi + if [[ "$content" =~ ^[[:space:]]*$ ]]; then continue; fi + + # Table header detection. + if [[ "$content" =~ ^[[:space:]]*\[([^]]+)\] ]]; then + header="${BASH_REMATCH[1]}" + case "$header" in + project) current_table="project_other"; current_key="" ;; + project.optional-dependencies) current_table="project_optional_deps"; current_key="" ;; + dependency-groups) current_table="dependency_groups"; current_key="" ;; + tool.uv) current_table="tool_uv"; current_key="" ;; + tool.poetry.dependencies) current_table="poetry_main"; current_key="" ;; + tool.poetry.dev-dependencies) current_table="poetry_dev"; current_key="" ;; + build-system) current_table="build_system"; current_key="" ;; + *) + case "$header" in + tool.poetry.group.*.dependencies) current_table="poetry_group"; current_key="" ;; + *) current_table="other"; current_key="" ;; + esac + ;; + esac + # Changed table header = structural change → disqualify. + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + continue + fi + + # Key = ... line — per-table routing. + if [[ "$content" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_-]*)[[:space:]]*=[[:space:]]*(.*)$ ]]; then + key="${BASH_REMATCH[1]}" + value="${BASH_REMATCH[2]}" + case "$current_table" in + project_other) + if [ "$key" = "dependencies" ]; then + # `dependencies = [` on +/- = structural change. + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; continue; fi + current_key="dependencies" + else + # Any other [project] key on +/- (name, version, description, ...) disqualifies. + current_key="" + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + fi + ;; + project_optional_deps|dependency_groups) + # Adding/renaming an extras key or dep group is structural. + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; continue; fi + current_key="$key" + ;; + tool_uv) + case "$key" in + constraint-dependencies|override-dependencies) + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; continue; fi + current_key="$key" + ;; + *) + current_key="" + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + ;; + esac + ;; + poetry_main|poetry_group|poetry_dev) + if [ "$key" = "python" ]; then + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + continue + fi + # String form? Try first. + if parse_poetry_keyval "$value"; then + if [ "$prefix" = "-" ]; then + pending_kind="poetry_keyval" + pending_name="$key" + pending_skeleton="" + pending_minus_version="$_poetry_keyval_version" + continue + fi + if [ "$prefix" = "+" ]; then + # Unmatched + (no preceding -) → new-dep addition → disqualify. + verdict="disqualified" + continue + fi + # Context line: nothing. + continue + fi + # Inline-table form? + if parse_poetry_inline "$value"; then + if [ "$prefix" = "-" ]; then + pending_kind="poetry_inline" + pending_name="$key" + pending_skeleton="$_poetry_inline_skeleton" + pending_minus_version="$_poetry_inline_version" + continue + fi + if [ "$prefix" = "+" ]; then + verdict="disqualified" + continue + fi + continue + fi + # Unrecognized poetry value form on a changed line → disqualify + # (git URL, path, editable, etc.). + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + ;; + build_system|other) + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + ;; + esac + continue + fi + + # PEP 508 string-in-array entry. + if parse_pep508_entry "$content"; then + # Positively-established array context required (Blocker 3). + in_array=false + case "$current_table" in + project_other) + [ "$current_key" = "dependencies" ] && in_array=true + ;; + project_optional_deps|dependency_groups) + [ -n "$current_key" ] && in_array=true + ;; + tool_uv) + case "$current_key" in + constraint-dependencies|override-dependencies) in_array=true ;; + esac + ;; + esac + if [ "$in_array" = "false" ]; then + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + continue + fi + if [ "$prefix" = "-" ]; then + pending_kind="pep508" + pending_name="$_pep_name" + pending_skeleton="${_pep_name}${_pep_extras}__VER__${_pep_marker}" + pending_minus_version="$_pep_version_spec" + continue + fi + if [ "$prefix" = "+" ]; then + # Unmatched + (no preceding -) → new-dep addition → disqualify. + verdict="disqualified" + continue + fi + continue + fi + + # Closing `]` of an array. On +/- this is reformatting/structural → disqualify. + # On a context line, the array we were tracking has closed → reset current_key + # so subsequent PEP 508-shaped lines do not inherit dependency-array context + # (any later entries need fresh context to re-establish in_array). + if [[ "$content" =~ ^[[:space:]]*\] ]]; then + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + current_key="" + continue + fi + + # Anything else changed in pyproject.toml → disqualify. + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi +done <<< "$input" + +flush_file + +if [ "$MODE" = "deps" ]; then + if [ ${#out_deps_rows[@]} -gt 0 ]; then + printf '%s\n' "${out_deps_rows[@]}" | sort -t$'\t' -k1,1 -u + fi +else + if [ ${#out_cleared_paths[@]} -gt 0 ]; then + printf '%s\n' "${out_cleared_paths[@]}" | sort -u + fi +fi diff --git a/tests/classify-touched-paths.bats b/tests/classify-touched-paths.bats index 556a2d4..2eea265 100644 --- a/tests/classify-touched-paths.bats +++ b/tests/classify-touched-paths.bats @@ -54,7 +54,11 @@ [ -z "$output" ] } -@test "pyproject.toml — unsupported" { +# Path-only classifier marks pyproject.toml unsupported. The +# dependency-cooldown/safety workflows may clear it via +# scripts/pyproject-bump-extract.sh after diff inspection (issue #66); +# this test exercises the classifier in isolation. +@test "pyproject.toml — unsupported (path-only)" { run bash -c 'printf "pyproject.toml\n" | bash scripts/classify-touched-paths.sh' [ "$status" -eq 0 ] [ "$output" = "pyproject.toml" ] diff --git a/tests/dep-guard-chain.bats b/tests/dep-guard-chain.bats index c98cfd9..aaa6fad 100644 --- a/tests/dep-guard-chain.bats +++ b/tests/dep-guard-chain.bats @@ -50,10 +50,41 @@ run_chain() { [ -z "$UNSUPPORTED_PATHS" ] } -@test "pyproject.toml only — TOUCHED=pyproject.toml, UNSUPPORTED=pyproject.toml" { - run_chain "$FIXTURES/pyproject-only.diff" - [ "$TOUCHED_PATHS" = "pyproject.toml" ] - [ "$UNSUPPORTED_PATHS" = "pyproject.toml" ] +# Mirrors the workflow composition block from §4.3 of the spec, including +# the CLEARED_PYPROJECT subtraction and the EFFECTIVE_TOUCHED computation. +run_chain_with_pyproject() { + local fixture="$1" + local diff_content + diff_content=$(cat "$fixture") + + # Note: extract-deps is invoked here without error suppression to match the + # workflow's behavior — its failures should surface. The new helper is + # guarded with `2>/dev/null || true` because exit-2 on malformed input is + # informational at the integration layer. + EXTRACTED=$(printf '%s' "$diff_content" | bash scripts/extract-deps.sh) + PYPROJECT_DEPS=$(printf '%s' "$diff_content" | bash scripts/pyproject-bump-extract.sh --mode=deps 2>/dev/null || true) + DEPS_TSV=$(printf '%s\n%s\n' "$EXTRACTED" "$PYPROJECT_DEPS" | sed '/^$/d' | sort -u) + + TOUCHED_PATHS=$(printf '%s' "$diff_content" | bash scripts/diff-touches-lockfile.sh 2>/dev/null || true) + BASE_UNSUPPORTED=$(printf '%s\n' "$TOUCHED_PATHS" | bash scripts/classify-touched-paths.sh 2>/dev/null || true) + CLEARED_PYPROJECT=$(printf '%s' "$diff_content" | bash scripts/pyproject-bump-extract.sh --mode=cleared-paths 2>/dev/null || true) + + UNSUPPORTED_PATHS="$BASE_UNSUPPORTED" + EFFECTIVE_TOUCHED="$TOUCHED_PATHS" + if [ -n "$(printf '%s\n' "$CLEARED_PYPROJECT" | sed '/^$/d')" ]; then + local cleared_file + cleared_file=$(mktemp "${TMPDIR:-/tmp}/cleared-pyproject-paths.XXXXXX") + printf '%s\n' "$CLEARED_PYPROJECT" | sed '/^$/d' | sort -u > "$cleared_file" + UNSUPPORTED_PATHS=$(printf '%s\n' "$BASE_UNSUPPORTED" | sed '/^$/d' | grep -vFxf "$cleared_file" || true) + EFFECTIVE_TOUCHED=$(printf '%s\n' "$TOUCHED_PATHS" | sed '/^$/d' | grep -vFxf "$cleared_file" || true) + rm -f "$cleared_file" + fi +} + +@test "pyproject-only Poetry bump — DEPS includes requests, UNSUPPORTED empty (issue #66)" { + run_chain_with_pyproject "$FIXTURES/pyproject-only.diff" + [[ "$DEPS_TSV" == *"requests"* ]] + [ -z "$UNSUPPORTED_PATHS" ] } @test "workflow YAML uses: bump — DEPS_TSV non-empty, UNSUPPORTED empty" { @@ -63,3 +94,57 @@ run_chain() { [ "$TOUCHED_PATHS" = ".github/workflows/ci.yml" ] [ -z "$UNSUPPORTED_PATHS" ] } + +@test "uv pyproject + uv.lock Dependabot bump passes guard (AC1)" { + run_chain_with_pyproject "$FIXTURES/uv-pyproject-plus-lock.diff" + [[ "$DEPS_TSV" == *"ruff"* ]] + [ -z "$UNSUPPORTED_PATHS" ] +} + +@test "poetry pyproject + poetry.lock Dependabot bump passes guard (AC2)" { + run_chain_with_pyproject "$FIXTURES/poetry-pyproject-plus-lock.diff" + [[ "$DEPS_TSV" == *"requests"* ]] + [ -z "$UNSUPPORTED_PATHS" ] +} + +@test "pyproject-only supported bump extracts and clears" { + run_chain_with_pyproject "$FIXTURES/uv-pyproject-only-bump.diff" + [[ "$DEPS_TSV" == *"ruff"* ]] + [ -z "$UNSUPPORTED_PATHS" ] +} + +@test "pyproject non-bump edit remains fail-loud (AC3)" { + run_chain_with_pyproject "$FIXTURES/pyproject-add-dep.diff" + # pyproject contributes no rows; if other helpers also produce none, DEPS is empty. + # The critical assertion is UNSUPPORTED still contains pyproject.toml. + [[ "$UNSUPPORTED_PATHS" == *"pyproject.toml"* ]] +} + +@test "mixed: cleared pyproject + unsupported package-lock.json" { + run_chain_with_pyproject "$FIXTURES/pyproject-bump-plus-npm.diff" + [[ "$DEPS_TSV" == *"ruff"* ]] + [[ "$UNSUPPORTED_PATHS" == *"package-lock.json"* ]] +} + +@test "pyproject disqualifier + uv.lock bump preserves AC4 fail-loud" { + run_chain_with_pyproject "$FIXTURES/pyproject-add-dep-plus-uvlock.diff" + # uv.lock contributes newpkg row from extract-deps. + [[ "$DEPS_TSV" == *"newpkg"* ]] + # pyproject still flagged unsupported. + [[ "$UNSUPPORTED_PATHS" == *"pyproject.toml"* ]] +} + +@test "cross-helper dedup: same package in pyproject + uv.lock yields one row" { + run_chain_with_pyproject "$FIXTURES/cross-helper-dedup.diff" + # Count ruff rows in DEPS_TSV. + ruff_rows=$(printf '%s\n' "$DEPS_TSV" | grep -c $'^ruff\t' || true) + [ "$ruff_rows" -eq 1 ] + [ -z "$UNSUPPORTED_PATHS" ] +} + +@test "comment-only pyproject does NOT trip Layer 3 zero-row guard" { + run_chain_with_pyproject "$FIXTURES/pyproject-comment-only.diff" + [ -z "$(echo "$DEPS_TSV" | sed '/^$/d')" ] + [ -z "$UNSUPPORTED_PATHS" ] + [ -z "$EFFECTIVE_TOUCHED" ] +} diff --git a/tests/fixtures/dep-guard-chain/cross-helper-dedup.diff b/tests/fixtures/dep-guard-chain/cross-helper-dedup.diff new file mode 100644 index 0000000..e89361c --- /dev/null +++ b/tests/fixtures/dep-guard-chain/cross-helper-dedup.diff @@ -0,0 +1,21 @@ +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,7 +10,7 @@ + [project] + dependencies = [ +- "ruff>=0.15.12", ++ "ruff>=0.15.13", + ] +diff --git a/uv.lock b/uv.lock +index 3333333..4444444 100644 +--- a/uv.lock ++++ b/uv.lock +@@ -100,7 +100,7 @@ wheels = [ + + [[package]] + name = "ruff" +-version = "0.15.12" ++version = "0.15.13" + source = { registry = "https://pypi.org/simple" } diff --git a/tests/fixtures/dep-guard-chain/poetry-pyproject-plus-lock.diff b/tests/fixtures/dep-guard-chain/poetry-pyproject-plus-lock.diff new file mode 100644 index 0000000..f626704 --- /dev/null +++ b/tests/fixtures/dep-guard-chain/poetry-pyproject-plus-lock.diff @@ -0,0 +1,20 @@ +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,7 +10,7 @@ + [tool.poetry.dependencies] + python = "^3.11" +-requests = "^2.31" ++requests = "^2.32" +diff --git a/poetry.lock b/poetry.lock +index 3333333..4444444 100644 +--- a/poetry.lock ++++ b/poetry.lock +@@ -50,7 +50,7 @@ optional = false + + [[package]] + name = "requests" +-version = "2.31.0" ++version = "2.32.0" + description = "..." diff --git a/tests/fixtures/dep-guard-chain/pyproject-add-dep-plus-uvlock.diff b/tests/fixtures/dep-guard-chain/pyproject-add-dep-plus-uvlock.diff new file mode 100644 index 0000000..d2fdd8c --- /dev/null +++ b/tests/fixtures/dep-guard-chain/pyproject-add-dep-plus-uvlock.diff @@ -0,0 +1,21 @@ +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,6 +10,7 @@ + [project] + dependencies = [ + "ruff>=0.15.13", ++ "newpkg>=1.0.0", + ] +diff --git a/uv.lock b/uv.lock +index 3333333..4444444 100644 +--- a/uv.lock ++++ b/uv.lock +@@ -100,7 +100,7 @@ wheels = [ + + [[package]] + name = "newpkg" +-version = "0.9.0" ++version = "1.0.0" + source = { registry = "https://pypi.org/simple" } diff --git a/tests/fixtures/dep-guard-chain/pyproject-add-dep.diff b/tests/fixtures/dep-guard-chain/pyproject-add-dep.diff new file mode 100644 index 0000000..dbd460f --- /dev/null +++ b/tests/fixtures/dep-guard-chain/pyproject-add-dep.diff @@ -0,0 +1,10 @@ +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,6 +10,7 @@ + [project] + dependencies = [ + "ruff>=0.15.13", ++ "newpkg>=1.0.0", + ] diff --git a/tests/fixtures/dep-guard-chain/pyproject-bump-plus-npm.diff b/tests/fixtures/dep-guard-chain/pyproject-bump-plus-npm.diff new file mode 100644 index 0000000..e3c1e08 --- /dev/null +++ b/tests/fixtures/dep-guard-chain/pyproject-bump-plus-npm.diff @@ -0,0 +1,23 @@ +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,7 +10,7 @@ + [project] + dependencies = [ +- "ruff>=0.15.12", ++ "ruff>=0.15.13", + ] +diff --git a/package-lock.json b/package-lock.json +index 3333333..4444444 100644 +--- a/package-lock.json ++++ b/package-lock.json +@@ -50,7 +50,7 @@ + "lockfileVersion": 3, + "dependencies": { + "left-pad": { +- "version": "1.3.0" ++ "version": "1.3.1" + } + } + } diff --git a/tests/fixtures/dep-guard-chain/pyproject-comment-only.diff b/tests/fixtures/dep-guard-chain/pyproject-comment-only.diff new file mode 100644 index 0000000..7adbc85 --- /dev/null +++ b/tests/fixtures/dep-guard-chain/pyproject-comment-only.diff @@ -0,0 +1,11 @@ +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,7 +10,7 @@ + [project] + dependencies = [ +- # comment about ruff ++ # updated comment about ruff + "ruff>=0.15.13", + ] diff --git a/tests/fixtures/dep-guard-chain/uv-pyproject-only-bump.diff b/tests/fixtures/dep-guard-chain/uv-pyproject-only-bump.diff new file mode 100644 index 0000000..5546bd7 --- /dev/null +++ b/tests/fixtures/dep-guard-chain/uv-pyproject-only-bump.diff @@ -0,0 +1,10 @@ +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,7 +10,7 @@ + [project] + dependencies = [ +- "ruff>=0.15.12", ++ "ruff>=0.15.13", + ] diff --git a/tests/fixtures/dep-guard-chain/uv-pyproject-plus-lock.diff b/tests/fixtures/dep-guard-chain/uv-pyproject-plus-lock.diff new file mode 100644 index 0000000..e89361c --- /dev/null +++ b/tests/fixtures/dep-guard-chain/uv-pyproject-plus-lock.diff @@ -0,0 +1,21 @@ +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,7 +10,7 @@ + [project] + dependencies = [ +- "ruff>=0.15.12", ++ "ruff>=0.15.13", + ] +diff --git a/uv.lock b/uv.lock +index 3333333..4444444 100644 +--- a/uv.lock ++++ b/uv.lock +@@ -100,7 +100,7 @@ wheels = [ + + [[package]] + name = "ruff" +-version = "0.15.12" ++version = "0.15.13" + source = { registry = "https://pypi.org/simple" } diff --git a/tests/fixtures/pyproject-bump-extract/comment-only-churn.diff b/tests/fixtures/pyproject-bump-extract/comment-only-churn.diff new file mode 100644 index 0000000..59c519b --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/comment-only-churn.diff @@ -0,0 +1,11 @@ +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,7 +10,7 @@ + [project] + dependencies = [ +- # bump ruff every release ++ # bump ruff every release (security review on majors) + "ruff>=0.15.13", + ] diff --git a/tests/fixtures/pyproject-bump-extract/multi-file-mixed.diff b/tests/fixtures/pyproject-bump-extract/multi-file-mixed.diff new file mode 100644 index 0000000..5f393a4 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/multi-file-mixed.diff @@ -0,0 +1,20 @@ +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,7 +10,8 @@ + [project] + dependencies = [ + "ruff>=0.15.13", ++ "newpkg>=1.0.0", + ] +diff --git a/services/api/pyproject.toml b/services/api/pyproject.toml +index 3333333..4444444 100644 +--- a/services/api/pyproject.toml ++++ b/services/api/pyproject.toml +@@ -10,7 +10,7 @@ + [project] + dependencies = [ +- "fastapi>=0.110.0", ++ "fastapi>=0.111.0", + ] diff --git a/tests/fixtures/pyproject-bump-extract/not-a-diff.txt b/tests/fixtures/pyproject-bump-extract/not-a-diff.txt new file mode 100644 index 0000000..f62a158 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/not-a-diff.txt @@ -0,0 +1 @@ +this is not a unified diff at all diff --git a/tests/fixtures/pyproject-bump-extract/pep621-dependencies-bump.diff b/tests/fixtures/pyproject-bump-extract/pep621-dependencies-bump.diff new file mode 100644 index 0000000..0b59356 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-dependencies-bump.diff @@ -0,0 +1,12 @@ +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,7 +10,7 @@ requires-python = ">=3.11" + + [project] + dependencies = [ +- "ruff>=0.15.12", ++ "ruff>=0.15.13", + "httpx>=0.27.0", + ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-optional-bump.diff b/tests/fixtures/pyproject-bump-extract/pep621-optional-bump.diff new file mode 100644 index 0000000..5c4e134 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-optional-bump.diff @@ -0,0 +1,11 @@ +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -20,7 +20,7 @@ dependencies = [ + + [project.optional-dependencies] + server = [ +- "httpx>=0.28.0", ++ "httpx>=0.28.1", + ] diff --git a/tests/fixtures/pyproject-bump-extract/pep735-group-bump.diff b/tests/fixtures/pyproject-bump-extract/pep735-group-bump.diff new file mode 100644 index 0000000..937f347 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep735-group-bump.diff @@ -0,0 +1,11 @@ +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -30,7 +30,7 @@ server = [ + + [dependency-groups] + dev = [ +- "pytest>=8.3.0", ++ "pytest>=8.4.0", + ] diff --git a/tests/fixtures/pyproject-bump-extract/poetry-dev-bump.diff b/tests/fixtures/pyproject-bump-extract/poetry-dev-bump.diff new file mode 100644 index 0000000..49d4548 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/poetry-dev-bump.diff @@ -0,0 +1,9 @@ +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -20,7 +20,7 @@ + [tool.poetry.dev-dependencies] +-black = "^24.1.0" ++black = "^24.2.0" + isort = "^5.13" diff --git a/tests/fixtures/pyproject-bump-extract/poetry-group-bump.diff b/tests/fixtures/pyproject-bump-extract/poetry-group-bump.diff new file mode 100644 index 0000000..a3d1c17 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/poetry-group-bump.diff @@ -0,0 +1,9 @@ +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -20,7 +20,7 @@ + [tool.poetry.group.dev.dependencies] +-pytest = "^8.3.0" ++pytest = "^8.4.0" + mypy = "^1.10" diff --git a/tests/fixtures/pyproject-bump-extract/poetry-inline-table-bump.diff b/tests/fixtures/pyproject-bump-extract/poetry-inline-table-bump.diff new file mode 100644 index 0000000..06e0821 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/poetry-inline-table-bump.diff @@ -0,0 +1,9 @@ +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,7 +10,7 @@ + [tool.poetry.dependencies] + python = "^3.11" +-ruff = { version = "^0.15.12", extras = ["server"] } ++ruff = { version = "^0.15.13", extras = ["server"] } diff --git a/tests/fixtures/pyproject-bump-extract/poetry-main-bump.diff b/tests/fixtures/pyproject-bump-extract/poetry-main-bump.diff new file mode 100644 index 0000000..9d5b7fe --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/poetry-main-bump.diff @@ -0,0 +1,10 @@ +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,7 +10,7 @@ + [tool.poetry.dependencies] + python = "^3.11" +-requests = "^2.31" ++requests = "^2.32" + click = "^8.1" diff --git a/tests/fixtures/pyproject-bump-extract/subdir-pyproject-bump.diff b/tests/fixtures/pyproject-bump-extract/subdir-pyproject-bump.diff new file mode 100644 index 0000000..940105b --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/subdir-pyproject-bump.diff @@ -0,0 +1,10 @@ +diff --git a/services/api/pyproject.toml b/services/api/pyproject.toml +index 1111111..2222222 100644 +--- a/services/api/pyproject.toml ++++ b/services/api/pyproject.toml +@@ -10,7 +10,7 @@ + [project] + dependencies = [ +- "fastapi>=0.110.0", ++ "fastapi>=0.111.0", + ] diff --git a/tests/fixtures/pyproject-bump-extract/uv-constraint-bump.diff b/tests/fixtures/pyproject-bump-extract/uv-constraint-bump.diff new file mode 100644 index 0000000..8ad71ed --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/uv-constraint-bump.diff @@ -0,0 +1,11 @@ +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -40,7 +40,7 @@ dev = [ + + [tool.uv] + constraint-dependencies = [ +- "urllib3>=2.4.0", ++ "urllib3>=2.5.0", + ] diff --git a/tests/fixtures/pyproject-bump-extract/uv-override-bump.diff b/tests/fixtures/pyproject-bump-extract/uv-override-bump.diff new file mode 100644 index 0000000..c959ecd --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/uv-override-bump.diff @@ -0,0 +1,11 @@ +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -40,7 +40,7 @@ dev = [ + + [tool.uv] + override-dependencies = [ +- "certifi>=2024.1.1", ++ "certifi>=2024.2.2", + ] diff --git a/tests/fixtures/pyproject-bump-extract/whitespace-churn-other-table.diff b/tests/fixtures/pyproject-bump-extract/whitespace-churn-other-table.diff new file mode 100644 index 0000000..e572008 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/whitespace-churn-other-table.diff @@ -0,0 +1,12 @@ +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -1,8 +1,9 @@ + [build-system] + requires = ["hatchling>=1.20.0"] + build-backend = "hatchling.build" ++ + + [project] + name = "foo" diff --git a/tests/guard-runtime.bats b/tests/guard-runtime.bats index fc7fe92..f890313 100644 --- a/tests/guard-runtime.bats +++ b/tests/guard-runtime.bats @@ -25,6 +25,7 @@ extract_guard_block() { block=$(extract_guard_block .github/workflows/dependency-cooldown.yml) DEPS_TSV=$'mypy\t1.20.1\tpypi' TOUCHED_PATHS=$'package-lock.json\nuv.lock' + EFFECTIVE_TOUCHED="$TOUCHED_PATHS" UNSUPPORTED_PATHS="package-lock.json" eval "$block" [ "$GUARD_TRIGGERED" = "true" ] @@ -35,6 +36,7 @@ extract_guard_block() { block=$(extract_guard_block .github/workflows/dependency-safety.yml) DEPS_TSV=$'mypy\t1.20.1\tpypi' TOUCHED_PATHS=$'package-lock.json\nuv.lock' + EFFECTIVE_TOUCHED="$TOUCHED_PATHS" UNSUPPORTED_PATHS="package-lock.json" eval "$block" [ "$GUARD_TRIGGERED" = "true" ] @@ -45,6 +47,7 @@ extract_guard_block() { block=$(extract_guard_block .github/workflows/dependency-cooldown.yml) DEPS_TSV="" TOUCHED_PATHS="uv.lock" + EFFECTIVE_TOUCHED="$TOUCHED_PATHS" UNSUPPORTED_PATHS="" eval "$block" [ "$GUARD_TRIGGERED" = "true" ] @@ -55,6 +58,7 @@ extract_guard_block() { block=$(extract_guard_block .github/workflows/dependency-safety.yml) DEPS_TSV="" TOUCHED_PATHS="uv.lock" + EFFECTIVE_TOUCHED="$TOUCHED_PATHS" UNSUPPORTED_PATHS="" eval "$block" [ "$GUARD_TRIGGERED" = "true" ] @@ -65,6 +69,7 @@ extract_guard_block() { block=$(extract_guard_block .github/workflows/dependency-cooldown.yml) DEPS_TSV=$'requests\t2.32.0\tpypi' TOUCHED_PATHS="requirements.txt" + EFFECTIVE_TOUCHED="$TOUCHED_PATHS" UNSUPPORTED_PATHS="" eval "$block" [ "$GUARD_TRIGGERED" = "false" ] @@ -74,6 +79,7 @@ extract_guard_block() { block=$(extract_guard_block .github/workflows/dependency-safety.yml) DEPS_TSV=$'requests\t2.32.0\tpypi' TOUCHED_PATHS="requirements.txt" + EFFECTIVE_TOUCHED="$TOUCHED_PATHS" UNSUPPORTED_PATHS="" eval "$block" [ "$GUARD_TRIGGERED" = "false" ] @@ -83,6 +89,7 @@ extract_guard_block() { block=$(extract_guard_block .github/workflows/dependency-cooldown.yml) DEPS_TSV="" TOUCHED_PATHS="" + EFFECTIVE_TOUCHED="" UNSUPPORTED_PATHS="" eval "$block" [ "$GUARD_TRIGGERED" = "false" ] @@ -92,6 +99,7 @@ extract_guard_block() { block=$(extract_guard_block .github/workflows/dependency-safety.yml) DEPS_TSV="" TOUCHED_PATHS="" + EFFECTIVE_TOUCHED="" UNSUPPORTED_PATHS="" eval "$block" [ "$GUARD_TRIGGERED" = "false" ] diff --git a/tests/pyproject-bump-extract.bats b/tests/pyproject-bump-extract.bats new file mode 100644 index 0000000..bc834bb --- /dev/null +++ b/tests/pyproject-bump-extract.bats @@ -0,0 +1,574 @@ +#!/usr/bin/env bats + +# Helper: asserts a fixture is fully disqualified. Both modes must emit +# zero output. Using this for every disqualifier test is mandatory — +# checking only --mode=deps cannot distinguish a disqualified file from +# a clean comment-only file (both emit no rows; only cleared-paths +# differs). +assert_disqualified() { + local fixture="$1" + run bash scripts/pyproject-bump-extract.sh --mode=deps < "$fixture" + [ "$status" -eq 0 ] + [ -z "$output" ] + run bash scripts/pyproject-bump-extract.sh --mode=cleared-paths < "$fixture" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +# Helper: asserts a fixture is a clean bump. Emits the expected single TSV +# row in deps mode and the expected path in cleared-paths mode. +assert_clean_bump() { + local fixture="$1" expected_row="$2" expected_path="$3" + run bash scripts/pyproject-bump-extract.sh --mode=deps < "$fixture" + [ "$status" -eq 0 ] + [ "$output" = "$expected_row" ] + run bash scripts/pyproject-bump-extract.sh --mode=cleared-paths < "$fixture" + [ "$status" -eq 0 ] + [ "$output" = "$expected_path" ] +} + +run_pyproject_deps() { + run bash -c 'bash scripts/pyproject-bump-extract.sh --mode=deps' <<< "$1" +} + +run_pyproject_cleared() { + run bash -c 'bash scripts/pyproject-bump-extract.sh --mode=cleared-paths' <<< "$1" +} + +assert_disqualified_diff() { + local diff="$1" + + run_pyproject_deps "$diff" + [ "$status" -eq 0 ] + [ -z "$output" ] + + run_pyproject_cleared "$diff" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +assert_clean_bump_diff() { + local diff="$1" expected_row="$2" expected_path="$3" + + run_pyproject_deps "$diff" + [ "$status" -eq 0 ] + [ "$output" = "$expected_row" ] + + run_pyproject_cleared "$diff" + [ "$status" -eq 0 ] + [ "$output" = "$expected_path" ] +} + +# Build a minimal PEP 621 [project] dependencies bump diff. One - and one + line. +# Args: $1 = minus body inside quotes (e.g. "ruff>=0.15.12") +# $2 = plus body inside quotes (e.g. "ruff>=0.15.13") +# Both args are inserted verbatim between the leading 4-space indent and the +# trailing comma. Hunk header is fixed at @@ -10,7 +10,7 @@. +pep621_deps_diff() { + printf '%s\n' \ + 'diff --git a/pyproject.toml b/pyproject.toml' \ + '--- a/pyproject.toml' \ + '+++ b/pyproject.toml' \ + '@@ -10,7 +10,7 @@' \ + ' [project]' \ + ' dependencies = [' \ + "- $1," \ + "+ $2," \ + ' ]' +} + +# Build a minimal Poetry [tool.poetry.dependencies] keyval diff. One - and one +. +# Args: $1 = minus body (e.g. 'pkg = "==1.0"') +# $2 = plus body (e.g. 'pkg = "^2.0"') +poetry_main_kv_diff() { + printf '%s\n' \ + 'diff --git a/pyproject.toml b/pyproject.toml' \ + '--- a/pyproject.toml' \ + '+++ b/pyproject.toml' \ + '@@ -1,2 +1,2 @@' \ + ' [tool.poetry.dependencies]' \ + "-$1" \ + "+$2" +} + +# Build a minimal Poetry [tool.poetry.dependencies] inline-table diff. +# Args: $1 = minus body (e.g. 'pkg = { version = "==1.0", source = "internal" }') +# $2 = plus body +poetry_inline_diff() { + printf '%s\n' \ + 'diff --git a/pyproject.toml b/pyproject.toml' \ + '--- a/pyproject.toml' \ + '+++ b/pyproject.toml' \ + '@@ -1,2 +1,2 @@' \ + ' [tool.poetry.dependencies]' \ + "-$1" \ + "+$2" +} + +@test "missing --mode exits 2 with stderr message" { + run bash -c 'printf "" | bash scripts/pyproject-bump-extract.sh' + [ "$status" -eq 2 ] + [[ "$output" == *"--mode=deps or --mode=cleared-paths required"* ]] +} + +@test "unknown --mode exits 2 with stderr message" { + run bash -c 'printf "" | bash scripts/pyproject-bump-extract.sh --mode=banana' + [ "$status" -eq 2 ] + [[ "$output" == *"unknown"* ]] || [[ "$output" == *"banana"* ]] +} + +@test "--mode specified twice exits 2" { + run bash -c 'printf "" | bash scripts/pyproject-bump-extract.sh --mode=deps --mode=cleared-paths' + [ "$status" -eq 2 ] +} + +@test "empty input with --mode=deps exits 0 with empty stdout" { + run bash -c 'printf "" | bash scripts/pyproject-bump-extract.sh --mode=deps' + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "empty input with --mode=cleared-paths exits 0 with empty stdout" { + run bash -c 'printf "" | bash scripts/pyproject-bump-extract.sh --mode=cleared-paths' + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "malformed input with --mode=deps exits 2" { + run bash scripts/pyproject-bump-extract.sh --mode=deps < tests/fixtures/pyproject-bump-extract/not-a-diff.txt + [ "$status" -eq 2 ] + [[ "$output" == *"input is not a unified diff"* ]] +} + +@test "malformed input with --mode=cleared-paths exits 2" { + run bash scripts/pyproject-bump-extract.sh --mode=cleared-paths < tests/fixtures/pyproject-bump-extract/not-a-diff.txt + [ "$status" -eq 2 ] + [[ "$output" == *"input is not a unified diff"* ]] +} + +@test "PEP 621 [project] dependencies bump — deps mode emits row" { + run bash scripts/pyproject-bump-extract.sh --mode=deps < tests/fixtures/pyproject-bump-extract/pep621-dependencies-bump.diff + [ "$status" -eq 0 ] + [ "$output" = "ruff 0.15.13 pypi" ] +} + +@test "PEP 621 [project] dependencies bump — cleared-paths mode emits path" { + run bash scripts/pyproject-bump-extract.sh --mode=cleared-paths < tests/fixtures/pyproject-bump-extract/pep621-dependencies-bump.diff + [ "$status" -eq 0 ] + [ "$output" = "pyproject.toml" ] +} + +@test "PEP 621 [project.optional-dependencies] bump" { + run bash scripts/pyproject-bump-extract.sh --mode=deps < tests/fixtures/pyproject-bump-extract/pep621-optional-bump.diff + [ "$status" -eq 0 ] + [ "$output" = "httpx 0.28.1 pypi" ] + run bash scripts/pyproject-bump-extract.sh --mode=cleared-paths < tests/fixtures/pyproject-bump-extract/pep621-optional-bump.diff + [ "$output" = "pyproject.toml" ] +} + +@test "PEP 735 [dependency-groups] bump" { + run bash scripts/pyproject-bump-extract.sh --mode=deps < tests/fixtures/pyproject-bump-extract/pep735-group-bump.diff + [ "$status" -eq 0 ] + [ "$output" = "pytest 8.4.0 pypi" ] + run bash scripts/pyproject-bump-extract.sh --mode=cleared-paths < tests/fixtures/pyproject-bump-extract/pep735-group-bump.diff + [ "$output" = "pyproject.toml" ] +} + +@test "uv [tool.uv] constraint-dependencies bump" { + run bash scripts/pyproject-bump-extract.sh --mode=deps < tests/fixtures/pyproject-bump-extract/uv-constraint-bump.diff + [ "$status" -eq 0 ] + [ "$output" = "urllib3 2.5.0 pypi" ] + run bash scripts/pyproject-bump-extract.sh --mode=cleared-paths < tests/fixtures/pyproject-bump-extract/uv-constraint-bump.diff + [ "$output" = "pyproject.toml" ] +} + +@test "uv [tool.uv] override-dependencies bump" { + run bash scripts/pyproject-bump-extract.sh --mode=deps < tests/fixtures/pyproject-bump-extract/uv-override-bump.diff + [ "$status" -eq 0 ] + [ "$output" = "certifi 2024.2.2 pypi" ] +} + +@test "Poetry main key=value bump" { + assert_clean_bump tests/fixtures/pyproject-bump-extract/poetry-main-bump.diff $'requests\t2.32\tpypi' "pyproject.toml" +} + +@test "Poetry python constraint change disqualifies" { + assert_disqualified_diff "$(cat <<'DIFF' +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,7 +10,7 @@ + [tool.poetry.dependencies] +-python = "^3.11" ++python = "^3.12" + requests = "^2.32" +DIFF +)" +} + +@test "Poetry inline-table version-only bump" { + assert_clean_bump tests/fixtures/pyproject-bump-extract/poetry-inline-table-bump.diff $'ruff\t0.15.13\tpypi' "pyproject.toml" +} + +@test "Poetry inline-table extras change disqualifies" { + assert_disqualified_diff "$(cat <<'DIFF' +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,7 +10,7 @@ + [tool.poetry.dependencies] + python = "^3.11" +-ruff = { version = "^0.15.13", extras = ["server"] } ++ruff = { version = "^0.15.13", extras = ["server", "lsp"] } +DIFF +)" +} + +@test "Poetry [tool.poetry.group.dev.dependencies] bump" { + run bash scripts/pyproject-bump-extract.sh --mode=deps < tests/fixtures/pyproject-bump-extract/poetry-group-bump.diff + [ "$status" -eq 0 ] + [ "$output" = "pytest 8.4.0 pypi" ] +} + +@test "Poetry legacy [tool.poetry.dev-dependencies] bump" { + run bash scripts/pyproject-bump-extract.sh --mode=deps < tests/fixtures/pyproject-bump-extract/poetry-dev-bump.diff + [ "$status" -eq 0 ] + [ "$output" = "black 24.2.0 pypi" ] +} + +@test "Subdir pyproject.toml bump emits subdir path" { + run bash scripts/pyproject-bump-extract.sh --mode=deps < tests/fixtures/pyproject-bump-extract/subdir-pyproject-bump.diff + [ "$status" -eq 0 ] + [ "$output" = "fastapi 0.111.0 pypi" ] + run bash scripts/pyproject-bump-extract.sh --mode=cleared-paths < tests/fixtures/pyproject-bump-extract/subdir-pyproject-bump.diff + [ "$output" = "services/api/pyproject.toml" ] +} + +@test "Multi-file: cleared subdir + disqualified root in same diff" { + run bash scripts/pyproject-bump-extract.sh --mode=deps < tests/fixtures/pyproject-bump-extract/multi-file-mixed.diff + [ "$status" -eq 0 ] + [ "$output" = "fastapi 0.111.0 pypi" ] + run bash scripts/pyproject-bump-extract.sh --mode=cleared-paths < tests/fixtures/pyproject-bump-extract/multi-file-mixed.diff + [ "$output" = "services/api/pyproject.toml" ] +} + +@test "Disqualify: PEP 621 new-dep addition" { + assert_disqualified_diff "$(cat <<'DIFF' +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,6 +10,7 @@ + [project] + dependencies = [ + "ruff>=0.15.13", ++ "newpkg>=1.0.0", + ] +DIFF +)" +} + +@test "Disqualify: PEP 621 dep removal (unmatched -)" { + assert_disqualified_diff "$(cat <<'DIFF' +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,7 +10,6 @@ + [project] + dependencies = [ + "ruff>=0.15.13", +- "oldpkg>=1.0.0", + ] +DIFF +)" +} + +@test "Disqualify: PEP 621 marker change" { + assert_disqualified_diff "$(pep621_deps_diff '"foo>=1.2; python_version < \"3.11\""' '"foo>=1.2; python_version < \"3.12\""')" +} + +@test "Disqualify: PEP 621 extras change" { + assert_disqualified_diff "$(pep621_deps_diff '"httpx[http2]>=0.28.0"' '"httpx[http2,brotli]>=0.28.0"')" +} + +@test "Disqualify: PEP 621 version + marker both change (skeleton mismatch)" { + assert_disqualified_diff "$(pep621_deps_diff '"foo>=1.1; python_version < '"'"'3.11'"'"'"' '"foo>=1.2; python_version < '"'"'3.12'"'"'"')" +} + +@test "Disqualify: PEP 621 version + extras both change (skeleton mismatch)" { + assert_disqualified_diff "$(pep621_deps_diff '"httpx[http2]>=0.28.0"' '"httpx[http2,brotli]>=0.28.1"')" +} + +@test "Disqualify: PEP 621 unmatched removal followed by context (pending lifetime)" { + assert_disqualified_diff "$(cat <<'DIFF' +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,7 +10,6 @@ + [project] + dependencies = [ +- "oldpkg>=1.0.0", + "ruff>=0.15.13", + ] +DIFF +)" +} + +@test "Disqualify: adding a new extras key in [project.optional-dependencies] (structural)" { + assert_disqualified_diff "$(cat <<'DIFF' +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,6 +10,9 @@ + [project.optional-dependencies] + server = [ + "httpx>=0.28.1", + ] ++lsp = [ ++ "lsprotocol>=2025.0.0", ++] +DIFF +)" +} + +@test "Disqualify: adding the dependencies = [ array to [project] (structural)" { + assert_disqualified_diff "$(cat <<'DIFF' +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -5,6 +5,9 @@ + [project] + name = "foo" + version = "0.1.0" ++dependencies = [ ++ "ruff>=0.15.13", ++] +DIFF +)" +} + +@test "Disqualify: build-system edit" { + assert_disqualified_diff "$(cat <<'DIFF' +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -1,6 +1,6 @@ + [build-system] + requires = [ +- "hatchling>=1.20.0", ++ "hatchling>=1.21.0", + ] + build-backend = "hatchling.build" +DIFF +)" +} + +@test "Disqualify: mid-array hunk with no header context" { + assert_disqualified_diff "$(cat <<'DIFF' +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -100,4 +100,4 @@ +- "ruff>=0.15.12", ++ "ruff>=0.15.13", + "httpx>=0.27.0", + ] +DIFF +)" +} + +@test "Disqualify: unrecognized table" { + assert_disqualified_diff "$(cat <<'DIFF' +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -50,7 +50,7 @@ + [tool.foo] +-bar = "old" ++bar = "new" +DIFF +)" +} + +@test "Disqualify: mixed bump + addition in same file" { + assert_disqualified_diff "$(cat <<'DIFF' +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,7 +10,8 @@ + [project] + dependencies = [ +- "ruff>=0.15.12", ++ "ruff>=0.15.13", ++ "newpkg>=1.0.0", + "httpx>=0.27.0", + ] +DIFF +)" +} + +@test "Comment-only churn clears path with zero deps" { + run bash scripts/pyproject-bump-extract.sh --mode=deps < tests/fixtures/pyproject-bump-extract/comment-only-churn.diff + [ "$status" -eq 0 ]; [ -z "$output" ] + run bash scripts/pyproject-bump-extract.sh --mode=cleared-paths < tests/fixtures/pyproject-bump-extract/comment-only-churn.diff + [ "$status" -eq 0 ]; [ "$output" = "pyproject.toml" ] +} + +@test "Whitespace-only churn in unrelated table clears path" { + run bash scripts/pyproject-bump-extract.sh --mode=deps < tests/fixtures/pyproject-bump-extract/whitespace-churn-other-table.diff + [ "$status" -eq 0 ]; [ -z "$output" ] + run bash scripts/pyproject-bump-extract.sh --mode=cleared-paths < tests/fixtures/pyproject-bump-extract/whitespace-churn-other-table.diff + [ "$status" -eq 0 ]; [ "$output" = "pyproject.toml" ] +} + +@test "Positive: package name with digits (urllib3)" { + DIFF="$(pep621_deps_diff '"urllib3>=2.4.0"' '"urllib3>=2.5.0"')" + run_pyproject_deps "$DIFF" + [ "$status" -eq 0 ] + [ "$output" = "urllib3 2.5.0 pypi" ] + run_pyproject_cleared "$DIFF" + [ "$output" = "pyproject.toml" ] +} + +@test "Positive: bump with unchanged marker preserved on both sides" { + DIFF="$(pep621_deps_diff '"foo>=1.1; python_version < \"3.12\""' '"foo>=1.2; python_version < \"3.12\""')" + run_pyproject_deps "$DIFF" + [ "$status" -eq 0 ] + [ "$output" = "foo 1.2 pypi" ] + run_pyproject_cleared "$DIFF" + [ "$output" = "pyproject.toml" ] +} + +@test "Positive: PEP 440 post-release version" { + DIFF="$(pep621_deps_diff '"pkg>=1.0.0"' '"pkg>=1.0.0.post1"')" + run_pyproject_deps "$DIFF" + [ "$status" -eq 0 ] + [ "$output" = "pkg 1.0.0.post1 pypi" ] + run_pyproject_cleared "$DIFF" + [ "$output" = "pyproject.toml" ] +} + +@test "Disqualify: PEP 508 compound spec (>=X,=0.15.12,<0.16"' '"ruff>=0.15.13,<0.16"')" +} + +@test "Disqualify: PEP 508 upper-bound change (=0.1", + ] + keywords = [ +- "docs>=1.0", ++ "docs>=2.0", + ] +DIFF +)" +} + +@test "Disqualify: current_key leak — uv dev-dependencies after constraint-dependencies (Bug 1)" { + assert_disqualified_diff "$(cat <<'DIFF' +diff --git a/pyproject.toml b/pyproject.toml +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -1,10 +1,10 @@ + [tool.uv] + constraint-dependencies = [ + "ruff>=0.1", + ] + dev-dependencies = [ +- "docs>=1.0", ++ "docs>=2.0", + ] +DIFF +)" +} + +@test "Disqualify: poetry inline-table subversion does not match version (Bug 2)" { + assert_disqualified_diff "$(poetry_inline_diff 'pkg = { subversion = "1.0.0", source = "internal" }' 'pkg = { subversion = "2.0.0", source = "internal" }')" +} + +@test "Disqualify: PEP 508 operator change (== to >=) is constraint broadening (Bug 3)" { + assert_disqualified_diff "$(cat <<'DIFF' +diff --git a/pyproject.toml b/pyproject.toml +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -1,5 +1,5 @@ + [project] + dependencies = [ +- "foo==1.0", ++ "foo>=2.0", + ] +DIFF +)" +} + +@test "Disqualify: poetry keyval operator change (== to ^) is constraint broadening (Bug 3)" { + assert_disqualified_diff "$(poetry_main_kv_diff 'pkg = "==1.0"' 'pkg = "^2.0"')" +} + +@test "Disqualify: poetry inline-table operator change (== to ^) is constraint broadening (Bug 3)" { + assert_disqualified_diff "$(poetry_inline_diff 'pkg = { version = "==1.0", source = "internal" }' 'pkg = { version = "^2.0", source = "internal" }')" +} + +@test "Disqualify: poetry inline-table whitespace around version= changed (Bug 4)" { + assert_disqualified_diff "$(poetry_inline_diff 'ruff = { version = "^0.15.12", extras = ["server"] }' 'ruff = { version="^0.15.13", extras = ["server"] }')" +} + +@test "Disqualify: malformed PEP 508 bare version (no operator) (Bug 5)" { + assert_disqualified_diff "$(cat <<'DIFF' +diff --git a/pyproject.toml b/pyproject.toml +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -1,4 +1,4 @@ + [project] + dependencies = [ +- "foo 1.0", ++ "foo 2.0", + ] +DIFF +)" +} + +@test "Disqualify: PEP 508 entries after a closing ] inherit no dependency context (Bug 1b)" { + assert_disqualified_diff "$(cat <<'DIFF' +diff --git a/pyproject.toml b/pyproject.toml +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -1,8 +1,8 @@ + [project] + dependencies = [ + "ruff>=0.1", + ] +- "docs>=1.0", ++ "docs>=2.0", + ] +DIFF +)" +}