From 0c75bf7093dff440ed10433608479b56695aff81 Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 20:06:35 -0700 Subject: [PATCH 01/24] fix(safety): scaffold pyproject-bump-extract helper with contract guards Adds the helper skeleton with flag parsing, empty/malformed input handling, and the bats test contract. Recognition logic for actual pyproject.toml hunks lands in subsequent commits. Refs #66. --- scripts/pyproject-bump-extract.sh | 64 +++++++++++++++++ .../pyproject-bump-extract/not-a-diff.txt | 1 + tests/pyproject-bump-extract.bats | 69 +++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100755 scripts/pyproject-bump-extract.sh create mode 100644 tests/fixtures/pyproject-bump-extract/not-a-diff.txt create mode 100644 tests/pyproject-bump-extract.bats diff --git a/scripts/pyproject-bump-extract.sh b/scripts/pyproject-bump-extract.sh new file mode 100755 index 0000000..03b2ae6 --- /dev/null +++ b/scripts/pyproject-bump-extract.sh @@ -0,0 +1,64 @@ +#!/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 (filled in by subsequent tasks). For now, no recognition logic; +# every diff with no pyproject.toml hunks correctly yields zero output. +exit 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/pyproject-bump-extract.bats b/tests/pyproject-bump-extract.bats new file mode 100644 index 0000000..fc9a43d --- /dev/null +++ b/tests/pyproject-bump-extract.bats @@ -0,0 +1,69 @@ +#!/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" ] +} + +@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"* ]] +} From 218f246b3614f4539470c9fe0f464c1941fe999b Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 20:11:03 -0700 Subject: [PATCH 02/24] fix(safety): pyproject parser handles PEP 621 [project] dependencies bumps Implements per-file state machine, table/key tracking, PEP 508 string-in-array bump-pair recognition, and target-version extraction (target-bearing operators only). Other recognized tables follow in subsequent commits. Refs #66. --- scripts/pyproject-bump-extract.sh | 386 +++++++++++++++++- .../pep621-dependencies-bump.diff | 12 + tests/pyproject-bump-extract.bats | 12 + 3 files changed, 407 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-dependencies-bump.diff diff --git a/scripts/pyproject-bump-extract.sh b/scripts/pyproject-bump-extract.sh index 03b2ae6..179292b 100755 --- a/scripts/pyproject-bump-extract.sh +++ b/scripts/pyproject-bump-extract.sh @@ -59,6 +59,386 @@ if ! grep -qE '^(\+\+\+|---|@@|diff --git)' <<< "$input"; then exit 2 fi -# Parser state (filled in by subsequent tasks). For now, no recognition logic; -# every diff with no pyproject.toml hunks correctly yields zero output. -exit 0 +# --- 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" + _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]}" + 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 substituted). +parse_poetry_inline() { + local value="$1" inner + _poetry_inline_version=""; _poetry_inline_skeleton="" + if [[ "$value" =~ ^[[:space:]]*\{(.*)\}[[:space:]]*$ ]]; then + inner="${BASH_REMATCH[1]}" + if [[ "$inner" =~ version[[:space:]]*=[[:space:]]*\"([^\"]*)\" ]]; then + _poetry_inline_version="${BASH_REMATCH[1]}" + _poetry_inline_skeleton=$(printf '%s' "$inner" | sed -E 's/version[[:space:]]*=[[:space:]]*"[^"]*"/version=__VER__/') + return 0 + fi + fi + return 1 +} + +emit_bump() { + local name="$1" plus_spec="$2" minus_spec="$3" target + if [ "$plus_spec" = "$minus_spec" ]; 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. + 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" + ;; + *) + 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 a changed line = reformatting → disqualify. + if [[ "$content" =~ ^[[:space:]]*\] ]]; then + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + 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/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/pyproject-bump-extract.bats b/tests/pyproject-bump-extract.bats index fc9a43d..ef546d6 100644 --- a/tests/pyproject-bump-extract.bats +++ b/tests/pyproject-bump-extract.bats @@ -67,3 +67,15 @@ assert_clean_bump() { [ "$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" ] +} From ca149363ae68df41e0fd01e3febe0b5a88de60c1 Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 20:14:52 -0700 Subject: [PATCH 03/24] fix(safety): cover PEP 621 optional-deps, PEP 735 groups, uv constraint/override All four use the PEP 508 string-in-array form; the existing state machine routes them via table-header recognition. Adds fixtures and assertions only. Refs #66. --- .../pep621-optional-bump.diff | 11 +++++++ .../pep735-group-bump.diff | 11 +++++++ .../uv-constraint-bump.diff | 11 +++++++ .../uv-override-bump.diff | 11 +++++++ tests/pyproject-bump-extract.bats | 30 +++++++++++++++++++ 5 files changed, 74 insertions(+) create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-optional-bump.diff create mode 100644 tests/fixtures/pyproject-bump-extract/pep735-group-bump.diff create mode 100644 tests/fixtures/pyproject-bump-extract/uv-constraint-bump.diff create mode 100644 tests/fixtures/pyproject-bump-extract/uv-override-bump.diff 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/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/pyproject-bump-extract.bats b/tests/pyproject-bump-extract.bats index ef546d6..f05fdeb 100644 --- a/tests/pyproject-bump-extract.bats +++ b/tests/pyproject-bump-extract.bats @@ -79,3 +79,33 @@ assert_clean_bump() { [ "$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" ] +} From f29b9e0da3fb0403e80bbaf0994cad46de535c5a Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 20:16:56 -0700 Subject: [PATCH 04/24] fix(safety): lock poetry main key=value and python-exclusion behavior Adds fixtures and tests for the canonical Poetry bump shape and the python-constraint disqualifier. The Task 2 parser already supports both via the centralized pending tracker and the python-key check in the poetry_main|poetry_group|poetry_dev branch. Refs #66. --- .../pyproject-bump-extract/poetry-main-bump.diff | 10 ++++++++++ .../poetry-python-constraint-change.diff | 9 +++++++++ tests/pyproject-bump-extract.bats | 8 ++++++++ 3 files changed, 27 insertions(+) create mode 100644 tests/fixtures/pyproject-bump-extract/poetry-main-bump.diff create mode 100644 tests/fixtures/pyproject-bump-extract/poetry-python-constraint-change.diff 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/poetry-python-constraint-change.diff b/tests/fixtures/pyproject-bump-extract/poetry-python-constraint-change.diff new file mode 100644 index 0000000..62d509b --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/poetry-python-constraint-change.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" ++python = "^3.12" + requests = "^2.32" diff --git a/tests/pyproject-bump-extract.bats b/tests/pyproject-bump-extract.bats index f05fdeb..c78e31c 100644 --- a/tests/pyproject-bump-extract.bats +++ b/tests/pyproject-bump-extract.bats @@ -109,3 +109,11 @@ assert_clean_bump() { [ "$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 tests/fixtures/pyproject-bump-extract/poetry-python-constraint-change.diff +} From 28a35c9e134410592d26273b7eaac055246ea064 Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 20:18:29 -0700 Subject: [PATCH 05/24] fix(safety): lock poetry inline-table version-only bump behavior Inline-table form (`name = { version = "spec", ... }`) is handled by the Task 2 parser via skeleton comparison: the inline body has `version = "..."` substituted with a sentinel and both sides must match byte-for-byte. Locks behavior with version-only-bump positive and extras-change negative. Refs #66. --- .../poetry-inline-extras-change.diff | 9 +++++++++ .../pyproject-bump-extract/poetry-inline-table-bump.diff | 9 +++++++++ tests/pyproject-bump-extract.bats | 8 ++++++++ 3 files changed, 26 insertions(+) create mode 100644 tests/fixtures/pyproject-bump-extract/poetry-inline-extras-change.diff create mode 100644 tests/fixtures/pyproject-bump-extract/poetry-inline-table-bump.diff diff --git a/tests/fixtures/pyproject-bump-extract/poetry-inline-extras-change.diff b/tests/fixtures/pyproject-bump-extract/poetry-inline-extras-change.diff new file mode 100644 index 0000000..aac876d --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/poetry-inline-extras-change.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.13", extras = ["server"] } ++ruff = { version = "^0.15.13", extras = ["server", "lsp"] } 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/pyproject-bump-extract.bats b/tests/pyproject-bump-extract.bats index c78e31c..d7033f9 100644 --- a/tests/pyproject-bump-extract.bats +++ b/tests/pyproject-bump-extract.bats @@ -117,3 +117,11 @@ assert_clean_bump() { @test "Poetry python constraint change disqualifies" { assert_disqualified tests/fixtures/pyproject-bump-extract/poetry-python-constraint-change.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 tests/fixtures/pyproject-bump-extract/poetry-inline-extras-change.diff +} From 7c34e22766b4e6b57a258a1bb379ccb5bb0628a9 Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 20:19:36 -0700 Subject: [PATCH 06/24] fix(safety): cover poetry group and legacy dev-dependencies tables Both route through the existing poetry key=value/inline-table logic via the state-machine's table-header recognition. Refs #66. --- .../pyproject-bump-extract/poetry-dev-bump.diff | 9 +++++++++ .../pyproject-bump-extract/poetry-group-bump.diff | 9 +++++++++ tests/pyproject-bump-extract.bats | 12 ++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 tests/fixtures/pyproject-bump-extract/poetry-dev-bump.diff create mode 100644 tests/fixtures/pyproject-bump-extract/poetry-group-bump.diff 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/pyproject-bump-extract.bats b/tests/pyproject-bump-extract.bats index d7033f9..a49aeb5 100644 --- a/tests/pyproject-bump-extract.bats +++ b/tests/pyproject-bump-extract.bats @@ -125,3 +125,15 @@ assert_clean_bump() { @test "Poetry inline-table extras change disqualifies" { assert_disqualified tests/fixtures/pyproject-bump-extract/poetry-inline-extras-change.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" ] +} From 1b9e932b2ad4a7621e25be52188e7f3cb4bc008f Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 20:20:41 -0700 Subject: [PATCH 07/24] fix(safety): cover subdir pyproject paths and multi-file independence Subdir paths surface as full b/... paths in cleared-paths output; verdict accumulation is per-file (one disqualified file does not affect a cleared sibling). Refs #66. --- .../multi-file-mixed.diff | 20 +++++++++++++++++++ .../subdir-pyproject-bump.diff | 10 ++++++++++ tests/pyproject-bump-extract.bats | 16 +++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 tests/fixtures/pyproject-bump-extract/multi-file-mixed.diff create mode 100644 tests/fixtures/pyproject-bump-extract/subdir-pyproject-bump.diff 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/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/pyproject-bump-extract.bats b/tests/pyproject-bump-extract.bats index a49aeb5..f324f8d 100644 --- a/tests/pyproject-bump-extract.bats +++ b/tests/pyproject-bump-extract.bats @@ -137,3 +137,19 @@ assert_clean_bump() { [ "$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" ] +} From 8999f1f597d787e19d2ee223449767a7ed4a9ceb Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 20:23:01 -0700 Subject: [PATCH 08/24] fix(safety): lock disqualification rules with negative test fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers every disqualification path: - add/remove/marker/extras-only changes - version+marker and version+extras combined changes (skeleton mismatch) - unmatched - lines followed by context (pending-lifetime enforcement) - structural changes: adding an extras key, adding the dependencies = [ array, adding/removing table headers - build-system, unrecognized tables, mid-array without header context Every test uses assert_disqualified which checks BOTH --mode=deps and --mode=cleared-paths emit empty output — checking only one cannot distinguish a disqualified file from a clean comment-only file. Refs #66. --- .../build-system-edit.diff | 11 ++++ .../mid-array-no-context.diff | 9 ++++ .../mixed-bump-and-add.diff | 12 +++++ .../pep621-add-dep.diff | 10 ++++ .../pep621-add-dependencies-array.diff | 11 ++++ .../pep621-add-extras-key.diff | 12 +++++ .../pep621-extras-change.diff | 10 ++++ .../pep621-marker-change.diff | 10 ++++ .../pep621-remove-dep.diff | 10 ++++ .../pep621-unmatched-removal.diff | 10 ++++ .../pep621-version-plus-extras-change.diff | 10 ++++ .../pep621-version-plus-marker-change.diff | 10 ++++ .../unrecognized-table-edit.diff | 8 +++ tests/pyproject-bump-extract.bats | 52 +++++++++++++++++++ 14 files changed, 185 insertions(+) create mode 100644 tests/fixtures/pyproject-bump-extract/build-system-edit.diff create mode 100644 tests/fixtures/pyproject-bump-extract/mid-array-no-context.diff create mode 100644 tests/fixtures/pyproject-bump-extract/mixed-bump-and-add.diff create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-add-dep.diff create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-add-dependencies-array.diff create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-add-extras-key.diff create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-extras-change.diff create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-marker-change.diff create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-remove-dep.diff create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-unmatched-removal.diff create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-version-plus-extras-change.diff create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-version-plus-marker-change.diff create mode 100644 tests/fixtures/pyproject-bump-extract/unrecognized-table-edit.diff diff --git a/tests/fixtures/pyproject-bump-extract/build-system-edit.diff b/tests/fixtures/pyproject-bump-extract/build-system-edit.diff new file mode 100644 index 0000000..0b65477 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/build-system-edit.diff @@ -0,0 +1,11 @@ +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 --git a/tests/fixtures/pyproject-bump-extract/mid-array-no-context.diff b/tests/fixtures/pyproject-bump-extract/mid-array-no-context.diff new file mode 100644 index 0000000..083a589 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/mid-array-no-context.diff @@ -0,0 +1,9 @@ +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 --git a/tests/fixtures/pyproject-bump-extract/mixed-bump-and-add.diff b/tests/fixtures/pyproject-bump-extract/mixed-bump-and-add.diff new file mode 100644 index 0000000..de029ce --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/mixed-bump-and-add.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,8 @@ + [project] + dependencies = [ +- "ruff>=0.15.12", ++ "ruff>=0.15.13", ++ "newpkg>=1.0.0", + "httpx>=0.27.0", + ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-add-dep.diff b/tests/fixtures/pyproject-bump-extract/pep621-add-dep.diff new file mode 100644 index 0000000..dbd460f --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-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/pyproject-bump-extract/pep621-add-dependencies-array.diff b/tests/fixtures/pyproject-bump-extract/pep621-add-dependencies-array.diff new file mode 100644 index 0000000..dccb68d --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-add-dependencies-array.diff @@ -0,0 +1,11 @@ +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 --git a/tests/fixtures/pyproject-bump-extract/pep621-add-extras-key.diff b/tests/fixtures/pyproject-bump-extract/pep621-add-extras-key.diff new file mode 100644 index 0000000..17f7f2f --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-add-extras-key.diff @@ -0,0 +1,12 @@ +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 --git a/tests/fixtures/pyproject-bump-extract/pep621-extras-change.diff b/tests/fixtures/pyproject-bump-extract/pep621-extras-change.diff new file mode 100644 index 0000000..95e19ec --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-extras-change.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 = [ +- "httpx[http2]>=0.28.0", ++ "httpx[http2,brotli]>=0.28.0", + ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-marker-change.diff b/tests/fixtures/pyproject-bump-extract/pep621-marker-change.diff new file mode 100644 index 0000000..da58b3b --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-marker-change.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 = [ +- "foo>=1.2; python_version < \"3.11\"", ++ "foo>=1.2; python_version < \"3.12\"", + ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-remove-dep.diff b/tests/fixtures/pyproject-bump-extract/pep621-remove-dep.diff new file mode 100644 index 0000000..8291637 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-remove-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,7 +10,6 @@ + [project] + dependencies = [ + "ruff>=0.15.13", +- "oldpkg>=1.0.0", + ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-unmatched-removal.diff b/tests/fixtures/pyproject-bump-extract/pep621-unmatched-removal.diff new file mode 100644 index 0000000..e5455e5 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-unmatched-removal.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,6 @@ + [project] + dependencies = [ +- "oldpkg>=1.0.0", + "ruff>=0.15.13", + ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-version-plus-extras-change.diff b/tests/fixtures/pyproject-bump-extract/pep621-version-plus-extras-change.diff new file mode 100644 index 0000000..54a73d7 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-version-plus-extras-change.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 = [ +- "httpx[http2]>=0.28.0", ++ "httpx[http2,brotli]>=0.28.1", + ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-version-plus-marker-change.diff b/tests/fixtures/pyproject-bump-extract/pep621-version-plus-marker-change.diff new file mode 100644 index 0000000..ca723b8 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-version-plus-marker-change.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 = [ +- "foo>=1.1; python_version < '3.11'", ++ "foo>=1.2; python_version < '3.12'", + ] diff --git a/tests/fixtures/pyproject-bump-extract/unrecognized-table-edit.diff b/tests/fixtures/pyproject-bump-extract/unrecognized-table-edit.diff new file mode 100644 index 0000000..0ae275e --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/unrecognized-table-edit.diff @@ -0,0 +1,8 @@ +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 --git a/tests/pyproject-bump-extract.bats b/tests/pyproject-bump-extract.bats index f324f8d..1932e0f 100644 --- a/tests/pyproject-bump-extract.bats +++ b/tests/pyproject-bump-extract.bats @@ -153,3 +153,55 @@ assert_clean_bump() { 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 tests/fixtures/pyproject-bump-extract/pep621-add-dep.diff +} + +@test "Disqualify: PEP 621 dep removal (unmatched -)" { + assert_disqualified tests/fixtures/pyproject-bump-extract/pep621-remove-dep.diff +} + +@test "Disqualify: PEP 621 marker change" { + assert_disqualified tests/fixtures/pyproject-bump-extract/pep621-marker-change.diff +} + +@test "Disqualify: PEP 621 extras change" { + assert_disqualified tests/fixtures/pyproject-bump-extract/pep621-extras-change.diff +} + +@test "Disqualify: PEP 621 version + marker both change (skeleton mismatch)" { + assert_disqualified tests/fixtures/pyproject-bump-extract/pep621-version-plus-marker-change.diff +} + +@test "Disqualify: PEP 621 version + extras both change (skeleton mismatch)" { + assert_disqualified tests/fixtures/pyproject-bump-extract/pep621-version-plus-extras-change.diff +} + +@test "Disqualify: PEP 621 unmatched removal followed by context (pending lifetime)" { + assert_disqualified tests/fixtures/pyproject-bump-extract/pep621-unmatched-removal.diff +} + +@test "Disqualify: adding a new extras key in [project.optional-dependencies] (structural)" { + assert_disqualified tests/fixtures/pyproject-bump-extract/pep621-add-extras-key.diff +} + +@test "Disqualify: adding the dependencies = [ array to [project] (structural)" { + assert_disqualified tests/fixtures/pyproject-bump-extract/pep621-add-dependencies-array.diff +} + +@test "Disqualify: build-system edit" { + assert_disqualified tests/fixtures/pyproject-bump-extract/build-system-edit.diff +} + +@test "Disqualify: mid-array hunk with no header context" { + assert_disqualified tests/fixtures/pyproject-bump-extract/mid-array-no-context.diff +} + +@test "Disqualify: unrecognized table" { + assert_disqualified tests/fixtures/pyproject-bump-extract/unrecognized-table-edit.diff +} + +@test "Disqualify: mixed bump + addition in same file" { + assert_disqualified tests/fixtures/pyproject-bump-extract/mixed-bump-and-add.diff +} From 2356888f9fca2389459655046c299c4b04a764f9 Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 20:24:39 -0700 Subject: [PATCH 09/24] fix(safety): comment and whitespace churn does not disqualify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per §3.4 rule 2, comment-only and whitespace-only changed lines are harmless and may appear anywhere in the diff. The path clears with zero extracted deps; downstream composition prevents this from tripping the Layer 3 zero-row guard (see EFFECTIVE_TOUCHED in Task 12). Refs #66. --- .../pyproject-bump-extract/comment-only-churn.diff | 11 +++++++++++ .../whitespace-churn-other-table.diff | 12 ++++++++++++ tests/pyproject-bump-extract.bats | 14 ++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 tests/fixtures/pyproject-bump-extract/comment-only-churn.diff create mode 100644 tests/fixtures/pyproject-bump-extract/whitespace-churn-other-table.diff 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/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/pyproject-bump-extract.bats b/tests/pyproject-bump-extract.bats index 1932e0f..cb97d5c 100644 --- a/tests/pyproject-bump-extract.bats +++ b/tests/pyproject-bump-extract.bats @@ -205,3 +205,17 @@ assert_clean_bump() { @test "Disqualify: mixed bump + addition in same file" { assert_disqualified tests/fixtures/pyproject-bump-extract/mixed-bump-and-add.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" ] +} From 7d777d194fb89b96e516a350af0784fd0206cc4d Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 20:41:51 -0700 Subject: [PATCH 10/24] fix(safety): support escaped-quote markers + lock target-version extraction edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §3.3.1 specifies that bumps with unchanged PEP 508 markers containing backslash-escaped double-quotes (e.g., "foo>=1.2; python_version < \"3.12\"") must extract normally. The parse_pep508_entry regex's marker portion was [^\"]*, which rejected this form and disqualified it via the catch-all path. Loosen the marker portion to .* — bash regex greediness + the trailing anchor pattern still force the correct closing quote. The 13 Task 8 disqualifier fixtures continue to disqualify (some via skeleton mismatch instead of unrecognized-line, same observable outcome since assert_disqualified checks both modes are empty). Positive: digit-bearing names, unchanged markers (incl. escaped), PEP 440 post-releases. Negative: compound specs, upper bounds, not-equal, wildcards. Operator allowlist is target-bearing only (^, ~, ~=, >=, ==, none). Refs #66. --- scripts/pyproject-bump-extract.sh | 2 +- .../pep621-compound-spec.diff | 10 ++++++ .../pep621-name-with-digits.diff | 10 ++++++ .../pep621-not-equal-change.diff | 10 ++++++ .../pep621-postrelease.diff | 10 ++++++ .../pep621-upper-bound-change.diff | 10 ++++++ .../pep621-with-unchanged-marker.diff | 10 ++++++ .../poetry-wildcard.diff | 8 +++++ tests/pyproject-bump-extract.bats | 34 +++++++++++++++++++ 9 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-compound-spec.diff create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-name-with-digits.diff create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-not-equal-change.diff create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-postrelease.diff create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-upper-bound-change.diff create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-with-unchanged-marker.diff create mode 100644 tests/fixtures/pyproject-bump-extract/poetry-wildcard.diff diff --git a/scripts/pyproject-bump-extract.sh b/scripts/pyproject-bump-extract.sh index 179292b..08d5d84 100755 --- a/scripts/pyproject-bump-extract.sh +++ b/scripts/pyproject-bump-extract.sh @@ -116,7 +116,7 @@ extract_target_version() { parse_pep508_entry() { local content="$1" _pep_name=""; _pep_extras=""; _pep_version_spec=""; _pep_marker="" - if [[ "$content" =~ ^[[:space:]]*\"([A-Za-z][A-Za-z0-9_.-]*)(\[[^]]*\])?([^\";]*)(;[^\"]*)?\"[[:space:]]*,?[[:space:]]*$ ]]; then + 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]}" diff --git a/tests/fixtures/pyproject-bump-extract/pep621-compound-spec.diff b/tests/fixtures/pyproject-bump-extract/pep621-compound-spec.diff new file mode 100644 index 0000000..7938d81 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-compound-spec.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,<0.16", ++ "ruff>=0.15.13,<0.16", + ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-name-with-digits.diff b/tests/fixtures/pyproject-bump-extract/pep621-name-with-digits.diff new file mode 100644 index 0000000..aaba901 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-name-with-digits.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 = [ +- "urllib3>=2.4.0", ++ "urllib3>=2.5.0", + ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-not-equal-change.diff b/tests/fixtures/pyproject-bump-extract/pep621-not-equal-change.diff new file mode 100644 index 0000000..2089cbc --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-not-equal-change.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 = [ +- "foo!=1.2.0", ++ "foo!=1.3.0", + ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-postrelease.diff b/tests/fixtures/pyproject-bump-extract/pep621-postrelease.diff new file mode 100644 index 0000000..fb461f0 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-postrelease.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 = [ +- "pkg>=1.0.0", ++ "pkg>=1.0.0.post1", + ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-upper-bound-change.diff b/tests/fixtures/pyproject-bump-extract/pep621-upper-bound-change.diff new file mode 100644 index 0000000..3fe1b11 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-upper-bound-change.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 = [ +- "foo<3.0", ++ "foo<4.0", + ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-with-unchanged-marker.diff b/tests/fixtures/pyproject-bump-extract/pep621-with-unchanged-marker.diff new file mode 100644 index 0000000..dc15500 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-with-unchanged-marker.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 = [ +- "foo>=1.1; python_version < \"3.12\"", ++ "foo>=1.2; python_version < \"3.12\"", + ] diff --git a/tests/fixtures/pyproject-bump-extract/poetry-wildcard.diff b/tests/fixtures/pyproject-bump-extract/poetry-wildcard.diff new file mode 100644 index 0000000..ab0b66b --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/poetry-wildcard.diff @@ -0,0 +1,8 @@ +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] +-foo = "1.0.0" ++foo = "*" diff --git a/tests/pyproject-bump-extract.bats b/tests/pyproject-bump-extract.bats index cb97d5c..08d7ed5 100644 --- a/tests/pyproject-bump-extract.bats +++ b/tests/pyproject-bump-extract.bats @@ -219,3 +219,37 @@ assert_clean_bump() { 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)" { + run bash scripts/pyproject-bump-extract.sh --mode=deps < tests/fixtures/pyproject-bump-extract/pep621-name-with-digits.diff + [ "$status" -eq 0 ] + [ "$output" = "urllib3 2.5.0 pypi" ] +} + +@test "Positive: bump with unchanged marker preserved on both sides" { + run bash scripts/pyproject-bump-extract.sh --mode=deps < tests/fixtures/pyproject-bump-extract/pep621-with-unchanged-marker.diff + [ "$status" -eq 0 ] + [ "$output" = "foo 1.2 pypi" ] +} + +@test "Positive: PEP 440 post-release version" { + run bash scripts/pyproject-bump-extract.sh --mode=deps < tests/fixtures/pyproject-bump-extract/pep621-postrelease.diff + [ "$status" -eq 0 ] + [ "$output" = "pkg 1.0.0.post1 pypi" ] +} + +@test "Disqualify: PEP 508 compound spec (>=X, Date: Sat, 23 May 2026 20:47:06 -0700 Subject: [PATCH 11/24] fix(safety): document diff-aware pyproject clearance in classifier header No behavior change. Notes that the workflow composition layer may clear pyproject.toml paths via the new diff-aware helper, while the classifier itself remains path-only. Refs #66. --- scripts/classify-touched-paths.sh | 7 +++++++ 1 file changed, 7 insertions(+) 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 From 3fdea4b3bb345825b8ce560f9e7e127ad003ee57 Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 20:48:47 -0700 Subject: [PATCH 12/24] fix(safety): re-sync classify-touched-paths inline copies after docstring update Mirrors the 6-line composition-layer note added to scripts/classify-touched-paths.sh in the previous commit so check-inline-sync.sh passes. Refs #66. --- .github/workflows/dependency-cooldown.yml | 7 +++++++ .github/workflows/dependency-safety.yml | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/.github/workflows/dependency-cooldown.yml b/.github/workflows/dependency-cooldown.yml index e36ca49..9cc87fd 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 diff --git a/.github/workflows/dependency-safety.yml b/.github/workflows/dependency-safety.yml index dae5a6f..ba830c4 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 From fc3e8062bae07ae626544728e2ac9b343edc48d3 Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 20:55:14 -0700 Subject: [PATCH 13/24] fix(safety): wire pyproject helper + EFFECTIVE_TOUCHED into dependency-safety Embeds pyproject_bump_extract as an inline function, computes CLEARED_PYPROJECT in the composition hoist, and updates Layer 3's zero-row condition to use EFFECTIVE_TOUCHED so cleared pyproject paths do not trip the guard. Layer 2 comment updated to reflect the new clearance pattern. guard-runtime.bats updated to supply EFFECTIVE_TOUCHED alongside TOUCHED_PATHS for safety workflow tests. Refs #66. --- .github/workflows/dependency-safety.yml | 490 +++++++++++++++++++++++- tests/guard-runtime.bats | 4 + 2 files changed, 483 insertions(+), 11 deletions(-) diff --git a/.github/workflows/dependency-safety.yml b/.github/workflows/dependency-safety.yml index ba830c4..777d4dc 100644 --- a/.github/workflows/dependency-safety.yml +++ b/.github/workflows/dependency-safety.yml @@ -322,6 +322,454 @@ 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" + _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]}" + 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 substituted). + parse_poetry_inline() { + local value="$1" inner + _poetry_inline_version=""; _poetry_inline_skeleton="" + if [[ "$value" =~ ^[[:space:]]*\{(.*)\}[[:space:]]*$ ]]; then + inner="${BASH_REMATCH[1]}" + if [[ "$inner" =~ version[[:space:]]*=[[:space:]]*\"([^\"]*)\" ]]; then + _poetry_inline_version="${BASH_REMATCH[1]}" + _poetry_inline_skeleton=$(printf '%s' "$inner" | sed -E 's/version[[:space:]]*=[[:space:]]*"[^"]*"/version=__VER__/') + return 0 + fi + fi + return 1 + } + + emit_bump() { + local name="$1" plus_spec="$2" minus_spec="$3" target + if [ "$plus_spec" = "$minus_spec" ]; 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. + 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" + ;; + *) + 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 a changed line = reformatting → disqualify. + if [[ "$content" =~ ^[[:space:]]*\] ]]; then + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + 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. @@ -686,20 +1134,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 @@ -725,9 +1193,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/tests/guard-runtime.bats b/tests/guard-runtime.bats index fc7fe92..15a762f 100644 --- a/tests/guard-runtime.bats +++ b/tests/guard-runtime.bats @@ -35,6 +35,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" ] @@ -55,6 +56,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" ] @@ -74,6 +76,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" ] @@ -92,6 +95,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" ] From 8205d2cdb7e68c04c03a082813c4f5fce1286a35 Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 21:00:07 -0700 Subject: [PATCH 14/24] fix(safety): mirror pyproject wiring into dependency-cooldown workflow Same four edits as the previous commit, applied to the legacy cooldown workflow that's retained for the Phase 2 migration window. Embedded function body is byte-identical to the safety workflow's. Refs #66. --- .github/workflows/dependency-cooldown.yml | 490 +++++++++++++++++++++- 1 file changed, 479 insertions(+), 11 deletions(-) diff --git a/.github/workflows/dependency-cooldown.yml b/.github/workflows/dependency-cooldown.yml index 9cc87fd..8bc70a5 100644 --- a/.github/workflows/dependency-cooldown.yml +++ b/.github/workflows/dependency-cooldown.yml @@ -318,6 +318,454 @@ 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" + _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]}" + 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 substituted). + parse_poetry_inline() { + local value="$1" inner + _poetry_inline_version=""; _poetry_inline_skeleton="" + if [[ "$value" =~ ^[[:space:]]*\{(.*)\}[[:space:]]*$ ]]; then + inner="${BASH_REMATCH[1]}" + if [[ "$inner" =~ version[[:space:]]*=[[:space:]]*\"([^\"]*)\" ]]; then + _poetry_inline_version="${BASH_REMATCH[1]}" + _poetry_inline_skeleton=$(printf '%s' "$inner" | sed -E 's/version[[:space:]]*=[[:space:]]*"[^"]*"/version=__VER__/') + return 0 + fi + fi + return 1 + } + + emit_bump() { + local name="$1" plus_spec="$2" minus_spec="$3" target + if [ "$plus_spec" = "$minus_spec" ]; 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. + 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" + ;; + *) + 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 a changed line = reformatting → disqualify. + if [[ "$content" =~ ^[[:space:]]*\] ]]; then + if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi + 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. @@ -551,20 +999,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 @@ -590,9 +1058,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 From 4ca4eb578795da287098f2ef6c8c9b3a9af28009 Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 21:01:48 -0700 Subject: [PATCH 15/24] fix(safety): track pyproject-bump-extract inline copies in CI sync guard Adds the two new INLINE_PAIRS entries so check-inline-sync.sh fails loudly if scripts/pyproject-bump-extract.sh drifts from the inline copies in either workflow. Refs #66. --- scripts/check-inline-sync.sh | 2 ++ 1 file changed, 2 insertions(+) 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 From 81010f2f9c5a86755780051a8acc51f323765148 Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 21:05:21 -0700 Subject: [PATCH 16/24] fix(safety): integration tests for pyproject helper composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds run_chain_with_pyproject helper that mirrors the workflow's full composition (including EFFECTIVE_TOUCHED), updates the existing pyproject-only assertion to reflect new clearance behavior, and adds acceptance-criterion coverage for AC1–AC4 and cross-helper dedup. Refs #66. --- tests/dep-guard-chain.bats | 93 ++++++++++++++++++- .../dep-guard-chain/cross-helper-dedup.diff | 21 +++++ .../poetry-pyproject-plus-lock.diff | 20 ++++ .../pyproject-add-dep-plus-uvlock.diff | 21 +++++ .../dep-guard-chain/pyproject-add-dep.diff | 10 ++ .../pyproject-bump-plus-npm.diff | 23 +++++ .../pyproject-comment-only.diff | 11 +++ .../uv-pyproject-only-bump.diff | 10 ++ .../uv-pyproject-plus-lock.diff | 21 +++++ 9 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/dep-guard-chain/cross-helper-dedup.diff create mode 100644 tests/fixtures/dep-guard-chain/poetry-pyproject-plus-lock.diff create mode 100644 tests/fixtures/dep-guard-chain/pyproject-add-dep-plus-uvlock.diff create mode 100644 tests/fixtures/dep-guard-chain/pyproject-add-dep.diff create mode 100644 tests/fixtures/dep-guard-chain/pyproject-bump-plus-npm.diff create mode 100644 tests/fixtures/dep-guard-chain/pyproject-comment-only.diff create mode 100644 tests/fixtures/dep-guard-chain/uv-pyproject-only-bump.diff create mode 100644 tests/fixtures/dep-guard-chain/uv-pyproject-plus-lock.diff 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" } From 9d8ddb7d1689a17a8cedf55a7f0243b4c66b45cd Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 21:06:45 -0700 Subject: [PATCH 17/24] fix(safety): clarify classifier's pyproject.toml unsupported test (path-only) Adds a comment noting that the workflow composition may clear this path via the diff-aware helper. Prevents a future reader from "fixing" this isolation test to reflect composition-level behavior. Refs #66. --- tests/classify-touched-paths.bats | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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" ] From 37aba9be17525b75f631d653f67b745bf137f044 Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 21:08:29 -0700 Subject: [PATCH 18/24] fix(safety): list pyproject-bump-extract as an inlined helper in CLAUDE.md Updates the workflow-roles description so the inline-embed listing includes the new helper alongside the existing four/five scripts. Refs #66. --- .claude/CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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:`. From aae32cd70d92773517f631f86cb4c88e8684f8b8 Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 22:26:26 -0700 Subject: [PATCH 19/24] fix(safety): supply EFFECTIVE_TOUCHED to cooldown guard-runtime tests The four `cooldown:` test cases in `tests/guard-runtime.bats` were not updated when the cooldown workflow's Layer 3 elif switched from `$TOUCHED_PATHS` to `$EFFECTIVE_TOUCHED`. The `safety:` counterparts got the new variable; the cooldown ones did not, so the issue #52 cooldown case began evaluating an unset `EFFECTIVE_TOUCHED` and the guard branch never fired. CI caught the regression even though local test output buried the failure. Mirrors the same pattern the safety tests use: bind `EFFECTIVE_TOUCHED` to `$TOUCHED_PATHS` (or empty when both should be empty), reflecting the workflow's composition behavior when no pyproject paths were cleared. Refs #66. --- tests/guard-runtime.bats | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/guard-runtime.bats b/tests/guard-runtime.bats index 15a762f..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" ] @@ -46,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" ] @@ -67,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" ] @@ -86,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" ] From 79fea4c2fb960121148c43ae36f97ceaa7827371 Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 23:28:16 -0700 Subject: [PATCH 20/24] fix(safety): close five silent-green parser bugs (PR #67 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR review found five paths where the parser would incorrectly extract or clear a pyproject.toml edit that should remain unsupported: 1. current_key leaked from [project].dependencies into a subsequent keywords = [ array (and equivalent in [tool.uv] for any non- constraint/override key), causing later PEP 508 entries to be treated as in a dependency array and silently cleared. 2. parse_poetry_inline matched 'version' anywhere in the inline body, including inside 'subversion', misclassifying unsupported inline tables as version bumps. 3. emit_bump compared only literal version specs and target versions — operator changes (==1.0 → >=2.0) passed as bumps even though they are semantic constraint broadenings. 4. The inline-table skeleton substitution replaced the entire 'version=...="..."' clause with a fixed form, normalizing away whitespace differences around '=' and masking reformatting+version changes. 5. Bare PEP 508 strings (e.g., 'foo 1.0') with a space instead of an operator were accepted, even though PEP 508 requires an operator before the version. Fixes: - Reset current_key when a [project] or [tool.uv] key is not a recognized dep-array key (covers context lines too). - Anchor 'version' in parse_poetry_inline to '^|,|whitespace' both in the match regex and the skeleton sed substitution. - Add extract_operator helper and require operator byte-equality before extracting versions in emit_bump. - Preserve original surrounding whitespace in the inline skeleton substitution so reformatting+bump is detected. - Validate that PEP 508 version_spec is empty or starts with a recognized operator in parse_pep508_entry. Adds 8 negative regression fixtures + tests using assert_disqualified so both --mode=deps and --mode=cleared-paths are checked. Refs #66. --- scripts/pyproject-bump-extract.sh | 48 ++++++++++++++++--- .../pep621-bare-version.diff | 9 ++++ .../pep621-current-key-leak-keywords.diff | 12 +++++ .../pep621-operator-change.diff | 9 ++++ .../poetry-inline-operator-change.diff | 7 +++ .../poetry-inline-subversion.diff | 7 +++ .../poetry-inline-whitespace-normalized.diff | 7 +++ .../poetry-keyval-operator-change.diff | 7 +++ .../uv-current-key-leak.diff | 12 +++++ tests/pyproject-bump-extract.bats | 32 +++++++++++++ 10 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-bare-version.diff create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-current-key-leak-keywords.diff create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-operator-change.diff create mode 100644 tests/fixtures/pyproject-bump-extract/poetry-inline-operator-change.diff create mode 100644 tests/fixtures/pyproject-bump-extract/poetry-inline-subversion.diff create mode 100644 tests/fixtures/pyproject-bump-extract/poetry-inline-whitespace-normalized.diff create mode 100644 tests/fixtures/pyproject-bump-extract/poetry-keyval-operator-change.diff create mode 100644 tests/fixtures/pyproject-bump-extract/uv-current-key-leak.diff diff --git a/scripts/pyproject-bump-extract.sh b/scripts/pyproject-bump-extract.sh index 08d5d84..24352c7 100755 --- a/scripts/pyproject-bump-extract.sh +++ b/scripts/pyproject-bump-extract.sh @@ -114,13 +114,22 @@ extract_target_version() { # 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" + 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 @@ -138,24 +147,49 @@ parse_poetry_keyval() { } # parse_poetry_inline "$value" — for { version = "spec", ... } form. -# Sets _poetry_inline_version and _poetry_inline_skeleton (version substituted). +# 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" =~ version[[:space:]]*=[[:space:]]*\"([^\"]*)\" ]]; then - _poetry_inline_version="${BASH_REMATCH[1]}" - _poetry_inline_skeleton=$(printf '%s' "$inner" | sed -E 's/version[[:space:]]*=[[:space:]]*"[^"]*"/version=__VER__/') + 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 + 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' @@ -318,6 +352,7 @@ while IFS= read -r line; do current_key="dependencies" else # Any other [project] key on +/- (name, version, description, ...) disqualifies. + current_key="" if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi fi ;; @@ -333,6 +368,7 @@ while IFS= read -r line; do current_key="$key" ;; *) + current_key="" if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi ;; esac diff --git a/tests/fixtures/pyproject-bump-extract/pep621-bare-version.diff b/tests/fixtures/pyproject-bump-extract/pep621-bare-version.diff new file mode 100644 index 0000000..3c8e300 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-bare-version.diff @@ -0,0 +1,9 @@ +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 --git a/tests/fixtures/pyproject-bump-extract/pep621-current-key-leak-keywords.diff b/tests/fixtures/pyproject-bump-extract/pep621-current-key-leak-keywords.diff new file mode 100644 index 0000000..f4df75f --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-current-key-leak-keywords.diff @@ -0,0 +1,12 @@ +diff --git a/pyproject.toml b/pyproject.toml +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -1,10 +1,10 @@ + [project] + dependencies = [ + "ruff>=0.1", + ] + keywords = [ +- "docs>=1.0", ++ "docs>=2.0", + ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-operator-change.diff b/tests/fixtures/pyproject-bump-extract/pep621-operator-change.diff new file mode 100644 index 0000000..110044a --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-operator-change.diff @@ -0,0 +1,9 @@ +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 --git a/tests/fixtures/pyproject-bump-extract/poetry-inline-operator-change.diff b/tests/fixtures/pyproject-bump-extract/poetry-inline-operator-change.diff new file mode 100644 index 0000000..a30711f --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/poetry-inline-operator-change.diff @@ -0,0 +1,7 @@ +diff --git a/pyproject.toml b/pyproject.toml +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -1,2 +1,2 @@ + [tool.poetry.dependencies] +-pkg = { version = "==1.0", source = "internal" } ++pkg = { version = "^2.0", source = "internal" } diff --git a/tests/fixtures/pyproject-bump-extract/poetry-inline-subversion.diff b/tests/fixtures/pyproject-bump-extract/poetry-inline-subversion.diff new file mode 100644 index 0000000..e8b0bb8 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/poetry-inline-subversion.diff @@ -0,0 +1,7 @@ +diff --git a/pyproject.toml b/pyproject.toml +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -1,4 +1,4 @@ + [tool.poetry.dependencies] +-pkg = { subversion = "1.0.0", source = "internal" } ++pkg = { subversion = "2.0.0", source = "internal" } diff --git a/tests/fixtures/pyproject-bump-extract/poetry-inline-whitespace-normalized.diff b/tests/fixtures/pyproject-bump-extract/poetry-inline-whitespace-normalized.diff new file mode 100644 index 0000000..5050f9e --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/poetry-inline-whitespace-normalized.diff @@ -0,0 +1,7 @@ +diff --git a/pyproject.toml b/pyproject.toml +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -1,2 +1,2 @@ + [tool.poetry.dependencies] +-ruff = { version = "^0.15.12", extras = ["server"] } ++ruff = { version="^0.15.13", extras = ["server"] } diff --git a/tests/fixtures/pyproject-bump-extract/poetry-keyval-operator-change.diff b/tests/fixtures/pyproject-bump-extract/poetry-keyval-operator-change.diff new file mode 100644 index 0000000..279edde --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/poetry-keyval-operator-change.diff @@ -0,0 +1,7 @@ +diff --git a/pyproject.toml b/pyproject.toml +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -1,2 +1,2 @@ + [tool.poetry.dependencies] +-pkg = "==1.0" ++pkg = "^2.0" diff --git a/tests/fixtures/pyproject-bump-extract/uv-current-key-leak.diff b/tests/fixtures/pyproject-bump-extract/uv-current-key-leak.diff new file mode 100644 index 0000000..d2a2bc8 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/uv-current-key-leak.diff @@ -0,0 +1,12 @@ +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 --git a/tests/pyproject-bump-extract.bats b/tests/pyproject-bump-extract.bats index 08d7ed5..27a7e68 100644 --- a/tests/pyproject-bump-extract.bats +++ b/tests/pyproject-bump-extract.bats @@ -253,3 +253,35 @@ assert_clean_bump() { @test "Disqualify: poetry wildcard (\"*\")" { assert_disqualified tests/fixtures/pyproject-bump-extract/poetry-wildcard.diff } + +@test "Disqualify: current_key leak — keywords array after dependencies (Bug 1)" { + assert_disqualified tests/fixtures/pyproject-bump-extract/pep621-current-key-leak-keywords.diff +} + +@test "Disqualify: current_key leak — uv dev-dependencies after constraint-dependencies (Bug 1)" { + assert_disqualified tests/fixtures/pyproject-bump-extract/uv-current-key-leak.diff +} + +@test "Disqualify: poetry inline-table subversion does not match version (Bug 2)" { + assert_disqualified tests/fixtures/pyproject-bump-extract/poetry-inline-subversion.diff +} + +@test "Disqualify: PEP 508 operator change (== to >=) is constraint broadening (Bug 3)" { + assert_disqualified tests/fixtures/pyproject-bump-extract/pep621-operator-change.diff +} + +@test "Disqualify: poetry keyval operator change (== to ^) is constraint broadening (Bug 3)" { + assert_disqualified tests/fixtures/pyproject-bump-extract/poetry-keyval-operator-change.diff +} + +@test "Disqualify: poetry inline-table operator change (== to ^) is constraint broadening (Bug 3)" { + assert_disqualified tests/fixtures/pyproject-bump-extract/poetry-inline-operator-change.diff +} + +@test "Disqualify: poetry inline-table whitespace around version= changed (Bug 4)" { + assert_disqualified tests/fixtures/pyproject-bump-extract/poetry-inline-whitespace-normalized.diff +} + +@test "Disqualify: malformed PEP 508 bare version (no operator) (Bug 5)" { + assert_disqualified tests/fixtures/pyproject-bump-extract/pep621-bare-version.diff +} From f5f7066b208704a6ea7f716a6d9a82da7f604597 Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 23:28:26 -0700 Subject: [PATCH 21/24] fix(safety): re-sync pyproject-bump-extract inline copies after parser fixes Mirrors the parser changes from the previous commit into the inline copies in both workflows so check-inline-sync.sh stays green. Refs #66. --- .github/workflows/dependency-cooldown.yml | 116 ++++++++++++++-------- .github/workflows/dependency-safety.yml | 48 +++++++-- 2 files changed, 118 insertions(+), 46 deletions(-) diff --git a/.github/workflows/dependency-cooldown.yml b/.github/workflows/dependency-cooldown.yml index 8bc70a5..6890ea2 100644 --- a/.github/workflows/dependency-cooldown.yml +++ b/.github/workflows/dependency-cooldown.yml @@ -341,9 +341,9 @@ jobs: # 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 @@ -360,26 +360,26 @@ jobs: ;; 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="" @@ -390,12 +390,12 @@ jobs: 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, @@ -404,9 +404,9 @@ jobs: 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 @@ -430,23 +430,32 @@ jobs: 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" + 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" @@ -457,45 +466,70 @@ jobs: fi return 1 } - + # parse_poetry_inline "$value" — for { version = "spec", ... } form. - # Sets _poetry_inline_version and _poetry_inline_skeleton (version substituted). + # 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" =~ version[[:space:]]*=[[:space:]]*\"([^\"]*)\" ]]; then - _poetry_inline_version="${BASH_REMATCH[1]}" - _poetry_inline_skeleton=$(printf '%s' "$inner" | sed -E 's/version[[:space:]]*=[[:space:]]*"[^"]*"/version=__VER__/') + 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 + 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 @@ -513,7 +547,7 @@ jobs: out_cleared_paths+=("$current_path") fi } - + reset_file_state() { current_table="" current_key="" @@ -521,11 +555,11 @@ jobs: 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 @@ -534,23 +568,23 @@ jobs: 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 @@ -599,11 +633,11 @@ jobs: 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]}" @@ -626,7 +660,7 @@ jobs: 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]}" @@ -639,6 +673,7 @@ jobs: current_key="dependencies" else # Any other [project] key on +/- (name, version, description, ...) disqualifies. + current_key="" if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi fi ;; @@ -654,6 +689,7 @@ jobs: current_key="$key" ;; *) + current_key="" if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi ;; esac @@ -705,7 +741,7 @@ jobs: esac continue fi - + # PEP 508 string-in-array entry. if parse_pep508_entry "$content"; then # Positively-established array context required (Blocker 3). @@ -741,19 +777,19 @@ jobs: fi continue fi - + # Closing `]` of an array on a changed line = reformatting → disqualify. if [[ "$content" =~ ^[[:space:]]*\] ]]; then if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi 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 diff --git a/.github/workflows/dependency-safety.yml b/.github/workflows/dependency-safety.yml index 777d4dc..3ebdd47 100644 --- a/.github/workflows/dependency-safety.yml +++ b/.github/workflows/dependency-safety.yml @@ -439,13 +439,22 @@ jobs: # 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" + 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 @@ -463,24 +472,49 @@ jobs: } # parse_poetry_inline "$value" — for { version = "spec", ... } form. - # Sets _poetry_inline_version and _poetry_inline_skeleton (version substituted). + # 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" =~ version[[:space:]]*=[[:space:]]*\"([^\"]*)\" ]]; then - _poetry_inline_version="${BASH_REMATCH[1]}" - _poetry_inline_skeleton=$(printf '%s' "$inner" | sed -E 's/version[[:space:]]*=[[:space:]]*"[^"]*"/version=__VER__/') + 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 + 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' @@ -643,6 +677,7 @@ jobs: current_key="dependencies" else # Any other [project] key on +/- (name, version, description, ...) disqualifies. + current_key="" if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi fi ;; @@ -658,6 +693,7 @@ jobs: current_key="$key" ;; *) + current_key="" if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi ;; esac From 544381491930d6bc8ccf14b677b7651d913cf2fa Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 23:40:09 -0700 Subject: [PATCH 22/24] fix(safety): reset current_key on closing ] to prevent post-close leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR review (Bug 1b) found that the previous fix only reset current_key when a non-dep-array key line was visible. If a recognized dependency array closed and was then followed by PEP 508-shaped lines without a fresh array-opening key in context (malformed/truncated hunk), the parser still treated those strings as dependency entries and could clear the path silently. Reset current_key on any closing `]` line — both +/- (which already disqualify as reformatting) and context. After a closing `]`, any later in-array detection requires fresh context to re-establish. Adds pep621-current-key-leak-post-close.diff with the exact reviewer reproducer and an assert_disqualified test that fails without the fix and passes with it. Inline copies in both workflows re-synced. Refs #66. --- .github/workflows/dependency-cooldown.yml | 6 +++++- .github/workflows/dependency-safety.yml | 6 +++++- scripts/pyproject-bump-extract.sh | 6 +++++- .../pep621-current-key-leak-post-close.diff | 11 +++++++++++ tests/pyproject-bump-extract.bats | 4 ++++ 5 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/pyproject-bump-extract/pep621-current-key-leak-post-close.diff diff --git a/.github/workflows/dependency-cooldown.yml b/.github/workflows/dependency-cooldown.yml index 6890ea2..490fb66 100644 --- a/.github/workflows/dependency-cooldown.yml +++ b/.github/workflows/dependency-cooldown.yml @@ -778,9 +778,13 @@ jobs: continue fi - # Closing `]` of an array on a changed line = reformatting → disqualify. + # 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 diff --git a/.github/workflows/dependency-safety.yml b/.github/workflows/dependency-safety.yml index 3ebdd47..8f0e82f 100644 --- a/.github/workflows/dependency-safety.yml +++ b/.github/workflows/dependency-safety.yml @@ -782,9 +782,13 @@ jobs: continue fi - # Closing `]` of an array on a changed line = reformatting → disqualify. + # 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 diff --git a/scripts/pyproject-bump-extract.sh b/scripts/pyproject-bump-extract.sh index 24352c7..7b10f70 100755 --- a/scripts/pyproject-bump-extract.sh +++ b/scripts/pyproject-bump-extract.sh @@ -457,9 +457,13 @@ while IFS= read -r line; do continue fi - # Closing `]` of an array on a changed line = reformatting → disqualify. + # 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 diff --git a/tests/fixtures/pyproject-bump-extract/pep621-current-key-leak-post-close.diff b/tests/fixtures/pyproject-bump-extract/pep621-current-key-leak-post-close.diff new file mode 100644 index 0000000..6dcec72 --- /dev/null +++ b/tests/fixtures/pyproject-bump-extract/pep621-current-key-leak-post-close.diff @@ -0,0 +1,11 @@ +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 --git a/tests/pyproject-bump-extract.bats b/tests/pyproject-bump-extract.bats index 27a7e68..cdbb52c 100644 --- a/tests/pyproject-bump-extract.bats +++ b/tests/pyproject-bump-extract.bats @@ -285,3 +285,7 @@ assert_clean_bump() { @test "Disqualify: malformed PEP 508 bare version (no operator) (Bug 5)" { assert_disqualified tests/fixtures/pyproject-bump-extract/pep621-bare-version.diff } + +@test "Disqualify: PEP 508 entries after a closing ] inherit no dependency context (Bug 1b)" { + assert_disqualified tests/fixtures/pyproject-bump-extract/pep621-current-key-leak-post-close.diff +} From b5566053546557422233c1cdab53b7a1d86cc061 Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 23 May 2026 23:53:08 -0700 Subject: [PATCH 23/24] fix(safety): inline 31 unit-level pyproject parser fixtures as heredocs Reduces fixture-file noise in PR #67 by moving 31 small unit-test diffs from tests/fixtures/pyproject-bump-extract/*.diff into literal heredocs inside tests/pyproject-bump-extract.bats. Keeps canonical positive fixtures and integration fixtures external (where literal files improve reviewability or are reused). Adds run_pyproject_deps, run_pyproject_cleared, assert_disqualified_diff, and assert_clean_bump_diff helpers that accept a diff string instead of a file path. Existing file-based helpers (assert_disqualified, assert_clean_bump) preserved for the 14 external fixtures still in use. Test names and assertions are unchanged; verified bats tests/ still reports the same count and zero failures. Refs #66. --- .../build-system-edit.diff | 11 - .../mid-array-no-context.diff | 9 - .../mixed-bump-and-add.diff | 12 - .../pep621-add-dep.diff | 10 - .../pep621-add-dependencies-array.diff | 11 - .../pep621-add-extras-key.diff | 12 - .../pep621-bare-version.diff | 9 - .../pep621-compound-spec.diff | 10 - .../pep621-current-key-leak-keywords.diff | 12 - .../pep621-current-key-leak-post-close.diff | 11 - .../pep621-extras-change.diff | 10 - .../pep621-marker-change.diff | 10 - .../pep621-name-with-digits.diff | 10 - .../pep621-not-equal-change.diff | 10 - .../pep621-operator-change.diff | 9 - .../pep621-postrelease.diff | 10 - .../pep621-remove-dep.diff | 10 - .../pep621-unmatched-removal.diff | 10 - .../pep621-upper-bound-change.diff | 10 - .../pep621-version-plus-extras-change.diff | 10 - .../pep621-version-plus-marker-change.diff | 10 - .../pep621-with-unchanged-marker.diff | 10 - .../poetry-inline-extras-change.diff | 9 - .../poetry-inline-operator-change.diff | 7 - .../poetry-inline-subversion.diff | 7 - .../poetry-inline-whitespace-normalized.diff | 7 - .../poetry-keyval-operator-change.diff | 7 - .../poetry-python-constraint-change.diff | 9 - .../poetry-wildcard.diff | 8 - .../unrecognized-table-edit.diff | 8 - .../uv-current-key-leak.diff | 12 - tests/pyproject-bump-extract.bats | 465 ++++++++++++++++-- 32 files changed, 434 insertions(+), 331 deletions(-) delete mode 100644 tests/fixtures/pyproject-bump-extract/build-system-edit.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/mid-array-no-context.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/mixed-bump-and-add.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/pep621-add-dep.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/pep621-add-dependencies-array.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/pep621-add-extras-key.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/pep621-bare-version.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/pep621-compound-spec.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/pep621-current-key-leak-keywords.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/pep621-current-key-leak-post-close.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/pep621-extras-change.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/pep621-marker-change.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/pep621-name-with-digits.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/pep621-not-equal-change.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/pep621-operator-change.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/pep621-postrelease.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/pep621-remove-dep.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/pep621-unmatched-removal.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/pep621-upper-bound-change.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/pep621-version-plus-extras-change.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/pep621-version-plus-marker-change.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/pep621-with-unchanged-marker.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/poetry-inline-extras-change.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/poetry-inline-operator-change.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/poetry-inline-subversion.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/poetry-inline-whitespace-normalized.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/poetry-keyval-operator-change.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/poetry-python-constraint-change.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/poetry-wildcard.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/unrecognized-table-edit.diff delete mode 100644 tests/fixtures/pyproject-bump-extract/uv-current-key-leak.diff diff --git a/tests/fixtures/pyproject-bump-extract/build-system-edit.diff b/tests/fixtures/pyproject-bump-extract/build-system-edit.diff deleted file mode 100644 index 0b65477..0000000 --- a/tests/fixtures/pyproject-bump-extract/build-system-edit.diff +++ /dev/null @@ -1,11 +0,0 @@ -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 --git a/tests/fixtures/pyproject-bump-extract/mid-array-no-context.diff b/tests/fixtures/pyproject-bump-extract/mid-array-no-context.diff deleted file mode 100644 index 083a589..0000000 --- a/tests/fixtures/pyproject-bump-extract/mid-array-no-context.diff +++ /dev/null @@ -1,9 +0,0 @@ -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 --git a/tests/fixtures/pyproject-bump-extract/mixed-bump-and-add.diff b/tests/fixtures/pyproject-bump-extract/mixed-bump-and-add.diff deleted file mode 100644 index de029ce..0000000 --- a/tests/fixtures/pyproject-bump-extract/mixed-bump-and-add.diff +++ /dev/null @@ -1,12 +0,0 @@ -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 --git a/tests/fixtures/pyproject-bump-extract/pep621-add-dep.diff b/tests/fixtures/pyproject-bump-extract/pep621-add-dep.diff deleted file mode 100644 index dbd460f..0000000 --- a/tests/fixtures/pyproject-bump-extract/pep621-add-dep.diff +++ /dev/null @@ -1,10 +0,0 @@ -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/pyproject-bump-extract/pep621-add-dependencies-array.diff b/tests/fixtures/pyproject-bump-extract/pep621-add-dependencies-array.diff deleted file mode 100644 index dccb68d..0000000 --- a/tests/fixtures/pyproject-bump-extract/pep621-add-dependencies-array.diff +++ /dev/null @@ -1,11 +0,0 @@ -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 --git a/tests/fixtures/pyproject-bump-extract/pep621-add-extras-key.diff b/tests/fixtures/pyproject-bump-extract/pep621-add-extras-key.diff deleted file mode 100644 index 17f7f2f..0000000 --- a/tests/fixtures/pyproject-bump-extract/pep621-add-extras-key.diff +++ /dev/null @@ -1,12 +0,0 @@ -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 --git a/tests/fixtures/pyproject-bump-extract/pep621-bare-version.diff b/tests/fixtures/pyproject-bump-extract/pep621-bare-version.diff deleted file mode 100644 index 3c8e300..0000000 --- a/tests/fixtures/pyproject-bump-extract/pep621-bare-version.diff +++ /dev/null @@ -1,9 +0,0 @@ -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 --git a/tests/fixtures/pyproject-bump-extract/pep621-compound-spec.diff b/tests/fixtures/pyproject-bump-extract/pep621-compound-spec.diff deleted file mode 100644 index 7938d81..0000000 --- a/tests/fixtures/pyproject-bump-extract/pep621-compound-spec.diff +++ /dev/null @@ -1,10 +0,0 @@ -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,<0.16", -+ "ruff>=0.15.13,<0.16", - ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-current-key-leak-keywords.diff b/tests/fixtures/pyproject-bump-extract/pep621-current-key-leak-keywords.diff deleted file mode 100644 index f4df75f..0000000 --- a/tests/fixtures/pyproject-bump-extract/pep621-current-key-leak-keywords.diff +++ /dev/null @@ -1,12 +0,0 @@ -diff --git a/pyproject.toml b/pyproject.toml ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -1,10 +1,10 @@ - [project] - dependencies = [ - "ruff>=0.1", - ] - keywords = [ -- "docs>=1.0", -+ "docs>=2.0", - ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-current-key-leak-post-close.diff b/tests/fixtures/pyproject-bump-extract/pep621-current-key-leak-post-close.diff deleted file mode 100644 index 6dcec72..0000000 --- a/tests/fixtures/pyproject-bump-extract/pep621-current-key-leak-post-close.diff +++ /dev/null @@ -1,11 +0,0 @@ -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 --git a/tests/fixtures/pyproject-bump-extract/pep621-extras-change.diff b/tests/fixtures/pyproject-bump-extract/pep621-extras-change.diff deleted file mode 100644 index 95e19ec..0000000 --- a/tests/fixtures/pyproject-bump-extract/pep621-extras-change.diff +++ /dev/null @@ -1,10 +0,0 @@ -diff --git a/pyproject.toml b/pyproject.toml -index 1111111..2222222 100644 ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -10,7 +10,7 @@ - [project] - dependencies = [ -- "httpx[http2]>=0.28.0", -+ "httpx[http2,brotli]>=0.28.0", - ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-marker-change.diff b/tests/fixtures/pyproject-bump-extract/pep621-marker-change.diff deleted file mode 100644 index da58b3b..0000000 --- a/tests/fixtures/pyproject-bump-extract/pep621-marker-change.diff +++ /dev/null @@ -1,10 +0,0 @@ -diff --git a/pyproject.toml b/pyproject.toml -index 1111111..2222222 100644 ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -10,7 +10,7 @@ - [project] - dependencies = [ -- "foo>=1.2; python_version < \"3.11\"", -+ "foo>=1.2; python_version < \"3.12\"", - ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-name-with-digits.diff b/tests/fixtures/pyproject-bump-extract/pep621-name-with-digits.diff deleted file mode 100644 index aaba901..0000000 --- a/tests/fixtures/pyproject-bump-extract/pep621-name-with-digits.diff +++ /dev/null @@ -1,10 +0,0 @@ -diff --git a/pyproject.toml b/pyproject.toml -index 1111111..2222222 100644 ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -10,7 +10,7 @@ - [project] - dependencies = [ -- "urllib3>=2.4.0", -+ "urllib3>=2.5.0", - ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-not-equal-change.diff b/tests/fixtures/pyproject-bump-extract/pep621-not-equal-change.diff deleted file mode 100644 index 2089cbc..0000000 --- a/tests/fixtures/pyproject-bump-extract/pep621-not-equal-change.diff +++ /dev/null @@ -1,10 +0,0 @@ -diff --git a/pyproject.toml b/pyproject.toml -index 1111111..2222222 100644 ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -10,7 +10,7 @@ - [project] - dependencies = [ -- "foo!=1.2.0", -+ "foo!=1.3.0", - ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-operator-change.diff b/tests/fixtures/pyproject-bump-extract/pep621-operator-change.diff deleted file mode 100644 index 110044a..0000000 --- a/tests/fixtures/pyproject-bump-extract/pep621-operator-change.diff +++ /dev/null @@ -1,9 +0,0 @@ -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 --git a/tests/fixtures/pyproject-bump-extract/pep621-postrelease.diff b/tests/fixtures/pyproject-bump-extract/pep621-postrelease.diff deleted file mode 100644 index fb461f0..0000000 --- a/tests/fixtures/pyproject-bump-extract/pep621-postrelease.diff +++ /dev/null @@ -1,10 +0,0 @@ -diff --git a/pyproject.toml b/pyproject.toml -index 1111111..2222222 100644 ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -10,7 +10,7 @@ - [project] - dependencies = [ -- "pkg>=1.0.0", -+ "pkg>=1.0.0.post1", - ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-remove-dep.diff b/tests/fixtures/pyproject-bump-extract/pep621-remove-dep.diff deleted file mode 100644 index 8291637..0000000 --- a/tests/fixtures/pyproject-bump-extract/pep621-remove-dep.diff +++ /dev/null @@ -1,10 +0,0 @@ -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 --git a/tests/fixtures/pyproject-bump-extract/pep621-unmatched-removal.diff b/tests/fixtures/pyproject-bump-extract/pep621-unmatched-removal.diff deleted file mode 100644 index e5455e5..0000000 --- a/tests/fixtures/pyproject-bump-extract/pep621-unmatched-removal.diff +++ /dev/null @@ -1,10 +0,0 @@ -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 --git a/tests/fixtures/pyproject-bump-extract/pep621-upper-bound-change.diff b/tests/fixtures/pyproject-bump-extract/pep621-upper-bound-change.diff deleted file mode 100644 index 3fe1b11..0000000 --- a/tests/fixtures/pyproject-bump-extract/pep621-upper-bound-change.diff +++ /dev/null @@ -1,10 +0,0 @@ -diff --git a/pyproject.toml b/pyproject.toml -index 1111111..2222222 100644 ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -10,7 +10,7 @@ - [project] - dependencies = [ -- "foo<3.0", -+ "foo<4.0", - ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-version-plus-extras-change.diff b/tests/fixtures/pyproject-bump-extract/pep621-version-plus-extras-change.diff deleted file mode 100644 index 54a73d7..0000000 --- a/tests/fixtures/pyproject-bump-extract/pep621-version-plus-extras-change.diff +++ /dev/null @@ -1,10 +0,0 @@ -diff --git a/pyproject.toml b/pyproject.toml -index 1111111..2222222 100644 ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -10,7 +10,7 @@ - [project] - dependencies = [ -- "httpx[http2]>=0.28.0", -+ "httpx[http2,brotli]>=0.28.1", - ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-version-plus-marker-change.diff b/tests/fixtures/pyproject-bump-extract/pep621-version-plus-marker-change.diff deleted file mode 100644 index ca723b8..0000000 --- a/tests/fixtures/pyproject-bump-extract/pep621-version-plus-marker-change.diff +++ /dev/null @@ -1,10 +0,0 @@ -diff --git a/pyproject.toml b/pyproject.toml -index 1111111..2222222 100644 ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -10,7 +10,7 @@ - [project] - dependencies = [ -- "foo>=1.1; python_version < '3.11'", -+ "foo>=1.2; python_version < '3.12'", - ] diff --git a/tests/fixtures/pyproject-bump-extract/pep621-with-unchanged-marker.diff b/tests/fixtures/pyproject-bump-extract/pep621-with-unchanged-marker.diff deleted file mode 100644 index dc15500..0000000 --- a/tests/fixtures/pyproject-bump-extract/pep621-with-unchanged-marker.diff +++ /dev/null @@ -1,10 +0,0 @@ -diff --git a/pyproject.toml b/pyproject.toml -index 1111111..2222222 100644 ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -10,7 +10,7 @@ - [project] - dependencies = [ -- "foo>=1.1; python_version < \"3.12\"", -+ "foo>=1.2; python_version < \"3.12\"", - ] diff --git a/tests/fixtures/pyproject-bump-extract/poetry-inline-extras-change.diff b/tests/fixtures/pyproject-bump-extract/poetry-inline-extras-change.diff deleted file mode 100644 index aac876d..0000000 --- a/tests/fixtures/pyproject-bump-extract/poetry-inline-extras-change.diff +++ /dev/null @@ -1,9 +0,0 @@ -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 --git a/tests/fixtures/pyproject-bump-extract/poetry-inline-operator-change.diff b/tests/fixtures/pyproject-bump-extract/poetry-inline-operator-change.diff deleted file mode 100644 index a30711f..0000000 --- a/tests/fixtures/pyproject-bump-extract/poetry-inline-operator-change.diff +++ /dev/null @@ -1,7 +0,0 @@ -diff --git a/pyproject.toml b/pyproject.toml ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -1,2 +1,2 @@ - [tool.poetry.dependencies] --pkg = { version = "==1.0", source = "internal" } -+pkg = { version = "^2.0", source = "internal" } diff --git a/tests/fixtures/pyproject-bump-extract/poetry-inline-subversion.diff b/tests/fixtures/pyproject-bump-extract/poetry-inline-subversion.diff deleted file mode 100644 index e8b0bb8..0000000 --- a/tests/fixtures/pyproject-bump-extract/poetry-inline-subversion.diff +++ /dev/null @@ -1,7 +0,0 @@ -diff --git a/pyproject.toml b/pyproject.toml ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -1,4 +1,4 @@ - [tool.poetry.dependencies] --pkg = { subversion = "1.0.0", source = "internal" } -+pkg = { subversion = "2.0.0", source = "internal" } diff --git a/tests/fixtures/pyproject-bump-extract/poetry-inline-whitespace-normalized.diff b/tests/fixtures/pyproject-bump-extract/poetry-inline-whitespace-normalized.diff deleted file mode 100644 index 5050f9e..0000000 --- a/tests/fixtures/pyproject-bump-extract/poetry-inline-whitespace-normalized.diff +++ /dev/null @@ -1,7 +0,0 @@ -diff --git a/pyproject.toml b/pyproject.toml ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -1,2 +1,2 @@ - [tool.poetry.dependencies] --ruff = { version = "^0.15.12", extras = ["server"] } -+ruff = { version="^0.15.13", extras = ["server"] } diff --git a/tests/fixtures/pyproject-bump-extract/poetry-keyval-operator-change.diff b/tests/fixtures/pyproject-bump-extract/poetry-keyval-operator-change.diff deleted file mode 100644 index 279edde..0000000 --- a/tests/fixtures/pyproject-bump-extract/poetry-keyval-operator-change.diff +++ /dev/null @@ -1,7 +0,0 @@ -diff --git a/pyproject.toml b/pyproject.toml ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -1,2 +1,2 @@ - [tool.poetry.dependencies] --pkg = "==1.0" -+pkg = "^2.0" diff --git a/tests/fixtures/pyproject-bump-extract/poetry-python-constraint-change.diff b/tests/fixtures/pyproject-bump-extract/poetry-python-constraint-change.diff deleted file mode 100644 index 62d509b..0000000 --- a/tests/fixtures/pyproject-bump-extract/poetry-python-constraint-change.diff +++ /dev/null @@ -1,9 +0,0 @@ -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 --git a/tests/fixtures/pyproject-bump-extract/poetry-wildcard.diff b/tests/fixtures/pyproject-bump-extract/poetry-wildcard.diff deleted file mode 100644 index ab0b66b..0000000 --- a/tests/fixtures/pyproject-bump-extract/poetry-wildcard.diff +++ /dev/null @@ -1,8 +0,0 @@ -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] --foo = "1.0.0" -+foo = "*" diff --git a/tests/fixtures/pyproject-bump-extract/unrecognized-table-edit.diff b/tests/fixtures/pyproject-bump-extract/unrecognized-table-edit.diff deleted file mode 100644 index 0ae275e..0000000 --- a/tests/fixtures/pyproject-bump-extract/unrecognized-table-edit.diff +++ /dev/null @@ -1,8 +0,0 @@ -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 --git a/tests/fixtures/pyproject-bump-extract/uv-current-key-leak.diff b/tests/fixtures/pyproject-bump-extract/uv-current-key-leak.diff deleted file mode 100644 index d2a2bc8..0000000 --- a/tests/fixtures/pyproject-bump-extract/uv-current-key-leak.diff +++ /dev/null @@ -1,12 +0,0 @@ -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 --git a/tests/pyproject-bump-extract.bats b/tests/pyproject-bump-extract.bats index cdbb52c..db72c60 100644 --- a/tests/pyproject-bump-extract.bats +++ b/tests/pyproject-bump-extract.bats @@ -27,6 +27,38 @@ assert_clean_bump() { [ "$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" ] +} + @test "missing --mode exits 2 with stderr message" { run bash -c 'printf "" | bash scripts/pyproject-bump-extract.sh' [ "$status" -eq 2 ] @@ -115,7 +147,18 @@ assert_clean_bump() { } @test "Poetry python constraint change disqualifies" { - assert_disqualified tests/fixtures/pyproject-bump-extract/poetry-python-constraint-change.diff + 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" { @@ -123,7 +166,18 @@ assert_clean_bump() { } @test "Poetry inline-table extras change disqualifies" { - assert_disqualified tests/fixtures/pyproject-bump-extract/poetry-inline-extras-change.diff + 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" { @@ -155,55 +209,214 @@ assert_clean_bump() { } @test "Disqualify: PEP 621 new-dep addition" { - assert_disqualified tests/fixtures/pyproject-bump-extract/pep621-add-dep.diff + 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 tests/fixtures/pyproject-bump-extract/pep621-remove-dep.diff + 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 tests/fixtures/pyproject-bump-extract/pep621-marker-change.diff + 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 @@ + [project] + dependencies = [ +- "foo>=1.2; python_version < \"3.11\"", ++ "foo>=1.2; python_version < \"3.12\"", + ] +DIFF +)" } @test "Disqualify: PEP 621 extras change" { - assert_disqualified tests/fixtures/pyproject-bump-extract/pep621-extras-change.diff + 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 @@ + [project] + dependencies = [ +- "httpx[http2]>=0.28.0", ++ "httpx[http2,brotli]>=0.28.0", + ] +DIFF +)" } @test "Disqualify: PEP 621 version + marker both change (skeleton mismatch)" { - assert_disqualified tests/fixtures/pyproject-bump-extract/pep621-version-plus-marker-change.diff + 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 @@ + [project] + dependencies = [ +- "foo>=1.1; python_version < '3.11'", ++ "foo>=1.2; python_version < '3.12'", + ] +DIFF +)" } @test "Disqualify: PEP 621 version + extras both change (skeleton mismatch)" { - assert_disqualified tests/fixtures/pyproject-bump-extract/pep621-version-plus-extras-change.diff + 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 @@ + [project] + dependencies = [ +- "httpx[http2]>=0.28.0", ++ "httpx[http2,brotli]>=0.28.1", + ] +DIFF +)" } @test "Disqualify: PEP 621 unmatched removal followed by context (pending lifetime)" { - assert_disqualified tests/fixtures/pyproject-bump-extract/pep621-unmatched-removal.diff + 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 tests/fixtures/pyproject-bump-extract/pep621-add-extras-key.diff + 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 tests/fixtures/pyproject-bump-extract/pep621-add-dependencies-array.diff + 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 tests/fixtures/pyproject-bump-extract/build-system-edit.diff + 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 tests/fixtures/pyproject-bump-extract/mid-array-no-context.diff + 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 tests/fixtures/pyproject-bump-extract/unrecognized-table-edit.diff + 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 tests/fixtures/pyproject-bump-extract/mixed-bump-and-add.diff + 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" { @@ -221,71 +434,261 @@ assert_clean_bump() { } @test "Positive: package name with digits (urllib3)" { - run bash scripts/pyproject-bump-extract.sh --mode=deps < tests/fixtures/pyproject-bump-extract/pep621-name-with-digits.diff + DIFF="$(cat <<'DIFF_EOF' +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,7 +10,7 @@ + [project] + dependencies = [ +- "urllib3>=2.4.0", ++ "urllib3>=2.5.0", + ] +DIFF_EOF +)" + 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" { - run bash scripts/pyproject-bump-extract.sh --mode=deps < tests/fixtures/pyproject-bump-extract/pep621-with-unchanged-marker.diff + DIFF="$(cat <<'DIFF_EOF' +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,7 +10,7 @@ + [project] + dependencies = [ +- "foo>=1.1; python_version < \"3.12\"", ++ "foo>=1.2; python_version < \"3.12\"", + ] +DIFF_EOF +)" + 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" { - run bash scripts/pyproject-bump-extract.sh --mode=deps < tests/fixtures/pyproject-bump-extract/pep621-postrelease.diff + DIFF="$(cat <<'DIFF_EOF' +diff --git a/pyproject.toml b/pyproject.toml +index 1111111..2222222 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -10,7 +10,7 @@ + [project] + dependencies = [ +- "pkg>=1.0.0", ++ "pkg>=1.0.0.post1", + ] +DIFF_EOF +)" + 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", + ] +DIFF +)" } @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 tests/fixtures/pyproject-bump-extract/uv-current-key-leak.diff + 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 tests/fixtures/pyproject-bump-extract/poetry-inline-subversion.diff + assert_disqualified_diff "$(cat <<'DIFF' +diff --git a/pyproject.toml b/pyproject.toml +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -1,4 +1,4 @@ + [tool.poetry.dependencies] +-pkg = { subversion = "1.0.0", source = "internal" } ++pkg = { subversion = "2.0.0", source = "internal" } +DIFF +)" } @test "Disqualify: PEP 508 operator change (== to >=) is constraint broadening (Bug 3)" { - assert_disqualified tests/fixtures/pyproject-bump-extract/pep621-operator-change.diff + 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 tests/fixtures/pyproject-bump-extract/poetry-keyval-operator-change.diff + assert_disqualified_diff "$(cat <<'DIFF' +diff --git a/pyproject.toml b/pyproject.toml +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -1,2 +1,2 @@ + [tool.poetry.dependencies] +-pkg = "==1.0" ++pkg = "^2.0" +DIFF +)" } @test "Disqualify: poetry inline-table operator change (== to ^) is constraint broadening (Bug 3)" { - assert_disqualified tests/fixtures/pyproject-bump-extract/poetry-inline-operator-change.diff + assert_disqualified_diff "$(cat <<'DIFF' +diff --git a/pyproject.toml b/pyproject.toml +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -1,2 +1,2 @@ + [tool.poetry.dependencies] +-pkg = { version = "==1.0", source = "internal" } ++pkg = { version = "^2.0", source = "internal" } +DIFF +)" } @test "Disqualify: poetry inline-table whitespace around version= changed (Bug 4)" { - assert_disqualified tests/fixtures/pyproject-bump-extract/poetry-inline-whitespace-normalized.diff + assert_disqualified_diff "$(cat <<'DIFF' +diff --git a/pyproject.toml b/pyproject.toml +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -1,2 +1,2 @@ + [tool.poetry.dependencies] +-ruff = { version = "^0.15.12", extras = ["server"] } ++ruff = { version="^0.15.13", extras = ["server"] } +DIFF +)" } @test "Disqualify: malformed PEP 508 bare version (no operator) (Bug 5)" { - assert_disqualified tests/fixtures/pyproject-bump-extract/pep621-bare-version.diff + 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 tests/fixtures/pyproject-bump-extract/pep621-current-key-leak-post-close.diff + 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 +)" } From c99ff0a41ac6600c4106bc6fa291196fa5e090d2 Mon Sep 17 00:00:00 2001 From: j7an Date: Sun, 24 May 2026 00:12:03 -0700 Subject: [PATCH 24/24] fix(safety): add diff builders for repetitive pyproject parser tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new helpers in tests/pyproject-bump-extract.bats build minimal diffs from minus/plus arg pairs: - pep621_deps_diff — PEP 621 [project] dependencies single ± - poetry_main_kv_diff — Poetry tool.poetry.dependencies keyval - poetry_inline_diff — Poetry inline-table single ± 15 disqualifier + 3 positive tests that previously used ~10-line heredocs now read as one-line builder calls with the minus/plus content as args. Test names and assertions unchanged. Tests with asymmetric or multi-line hunks keep their heredocs since the builders do not fit. Refs #66. --- tests/pyproject-bump-extract.bats | 242 ++++++++---------------------- 1 file changed, 61 insertions(+), 181 deletions(-) diff --git a/tests/pyproject-bump-extract.bats b/tests/pyproject-bump-extract.bats index db72c60..bc834bb 100644 --- a/tests/pyproject-bump-extract.bats +++ b/tests/pyproject-bump-extract.bats @@ -59,6 +59,52 @@ assert_clean_bump_diff() { [ "$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 ] @@ -241,67 +287,19 @@ DIFF } @test "Disqualify: PEP 621 marker change" { - 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 @@ - [project] - dependencies = [ -- "foo>=1.2; python_version < \"3.11\"", -+ "foo>=1.2; python_version < \"3.12\"", - ] -DIFF -)" + 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 "$(cat <<'DIFF' -diff --git a/pyproject.toml b/pyproject.toml -index 1111111..2222222 100644 ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -10,7 +10,7 @@ - [project] - dependencies = [ -- "httpx[http2]>=0.28.0", -+ "httpx[http2,brotli]>=0.28.0", - ] -DIFF -)" + 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 "$(cat <<'DIFF' -diff --git a/pyproject.toml b/pyproject.toml -index 1111111..2222222 100644 ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -10,7 +10,7 @@ - [project] - dependencies = [ -- "foo>=1.1; python_version < '3.11'", -+ "foo>=1.2; python_version < '3.12'", - ] -DIFF -)" + 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 "$(cat <<'DIFF' -diff --git a/pyproject.toml b/pyproject.toml -index 1111111..2222222 100644 ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -10,7 +10,7 @@ - [project] - dependencies = [ -- "httpx[http2]>=0.28.0", -+ "httpx[http2,brotli]>=0.28.1", - ] -DIFF -)" + 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)" { @@ -434,19 +432,7 @@ DIFF } @test "Positive: package name with digits (urllib3)" { - DIFF="$(cat <<'DIFF_EOF' -diff --git a/pyproject.toml b/pyproject.toml -index 1111111..2222222 100644 ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -10,7 +10,7 @@ - [project] - dependencies = [ -- "urllib3>=2.4.0", -+ "urllib3>=2.5.0", - ] -DIFF_EOF -)" + 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" ] @@ -455,19 +441,7 @@ DIFF_EOF } @test "Positive: bump with unchanged marker preserved on both sides" { - DIFF="$(cat <<'DIFF_EOF' -diff --git a/pyproject.toml b/pyproject.toml -index 1111111..2222222 100644 ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -10,7 +10,7 @@ - [project] - dependencies = [ -- "foo>=1.1; python_version < \"3.12\"", -+ "foo>=1.2; python_version < \"3.12\"", - ] -DIFF_EOF -)" + 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" ] @@ -476,19 +450,7 @@ DIFF_EOF } @test "Positive: PEP 440 post-release version" { - DIFF="$(cat <<'DIFF_EOF' -diff --git a/pyproject.toml b/pyproject.toml -index 1111111..2222222 100644 ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -10,7 +10,7 @@ - [project] - dependencies = [ -- "pkg>=1.0.0", -+ "pkg>=1.0.0.post1", - ] -DIFF_EOF -)" + 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" ] @@ -497,65 +459,19 @@ DIFF_EOF } @test "Disqualify: PEP 508 compound spec (>=X,=0.15.12,<0.16", -+ "ruff>=0.15.13,<0.16", - ] -DIFF -)" + assert_disqualified_diff "$(pep621_deps_diff '"ruff>=0.15.12,<0.16"' '"ruff>=0.15.13,<0.16"')" } @test "Disqualify: PEP 508 upper-bound change (=) is constraint broadening (Bug 3)" { @@ -623,42 +530,15 @@ DIFF } @test "Disqualify: poetry keyval 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,2 +1,2 @@ - [tool.poetry.dependencies] --pkg = "==1.0" -+pkg = "^2.0" -DIFF -)" + 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 "$(cat <<'DIFF' -diff --git a/pyproject.toml b/pyproject.toml ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -1,2 +1,2 @@ - [tool.poetry.dependencies] --pkg = { version = "==1.0", source = "internal" } -+pkg = { version = "^2.0", source = "internal" } -DIFF -)" + 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 "$(cat <<'DIFF' -diff --git a/pyproject.toml b/pyproject.toml ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -1,2 +1,2 @@ - [tool.poetry.dependencies] --ruff = { version = "^0.15.12", extras = ["server"] } -+ruff = { version="^0.15.13", extras = ["server"] } -DIFF -)" + 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)" {