diff --git a/.github/workflows/dependency-cooldown.yml b/.github/workflows/dependency-cooldown.yml index 3ea31d0..e36ca49 100644 --- a/.github/workflows/dependency-cooldown.yml +++ b/.github/workflows/dependency-cooldown.yml @@ -257,6 +257,60 @@ jobs: ) # --- END inline:scripts/diff-touches-lockfile.sh --- + # --- BEGIN inline:scripts/classify-touched-paths.sh --- + classify_touched_paths() ( + # classify-touched-paths.sh — filter dependency-relevant paths down to + # those that extract-deps.sh does NOT parse. + # + # Input: newline-delimited paths on stdin (typically the output of + # diff-touches-lockfile.sh). + # Output: subset of input paths whose filename/location is NOT in the + # extract-deps.sh supported set, sorted & deduped, one per line. + # Empty stdout when every input path is supported. + # Exit: 0 on success (zero or more output rows). No exit-2 path: malformed + # input here is "an unrecognised filename" which is exactly what we + # report on stdout. + # + # Supported set (must stay in sync with scripts/extract-deps.sh): + # - .github/workflows/*.yml | *.yaml (GitHub Actions `uses:` line parser) + # - uv.lock | poetry.lock (TOML [[package]] stanza parser) + # - requirements*.txt (pip line-shape parser) + # + # `requirements*.txt` is supported by line-shape parsing, not by a full + # requirements-file parser. The extractor recognizes added requirement lines + # with operators ==, >=, <=, ~=, !=, >, or < and a numeric version prefix; + # comments, includes like `-r other.txt`, hash/check option lines, and other + # requirements-file structure are ignored. Standard Dependabot bumps reliably + # surface as added pinned requirement lines, which is what we promise. + # + # Everything else diff-touches-lockfile.sh emits — pyproject.toml, Pipfile, + # Pipfile.lock, go.mod, Cargo.toml, Cargo.lock, package.json, + # package-lock.json, yarn.lock, pnpm-lock.yaml, and any other *.lock — is + # 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. + + set -euo pipefail + + while IFS= read -r path || [ -n "$path" ]; do + [ -z "$path" ] && continue + base="${path##*/}" + + # Supported (drop from output). + case "$path" in + .github/workflows/*.yml|.github/workflows/*.yaml) continue ;; + esac + case "$base" in + uv.lock|poetry.lock) continue ;; + requirements*.txt) continue ;; + esac + + # Anything else: emit as unsupported. + printf '%s\n' "$path" + done | sort -u + ) + # --- END inline:scripts/classify-touched-paths.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. @@ -492,18 +546,19 @@ jobs: # --- Extract dependencies via standalone script (Task 8) --- DEPS_TSV=$(echo "$DIFF" | extract_deps) + # --- 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). + 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) + # --- Layer 2: PR-body fallback (defense in depth for issue #52) --- - TOUCHED_PATHS="" + # 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. if [ -z "$(echo "$DEPS_TSV" | sed '/^$/d')" ]; then - TOUCHED_PATHS=$(echo "$DIFF" | diff_touches_lockfile 2>/dev/null || true) - # Keep the pypi regex below in sync with filename_to_ecosystem() in - # scripts/extract-deps.sh — ONLY lockfiles the parser actually handles - # should route to the pypi fallback. Cargo.lock, yarn.lock, etc. are - # emitted by diff_touches_lockfile (for guard detection) but must NOT - # route here — they fall through and trip the fail-loud guard instead. - # Mixed-touch policy: if a PR touches BOTH a pypi lockfile AND an actions - # workflow YAML, pypi wins. The fallback parser handles one ecosystem per - # PR; actions bumps (if any) fall through to the guard and will refuse green. ECO_HINT="" if echo "$TOUCHED_PATHS" | grep -qE '((^|/)(uv|poetry)\.lock$|requirements[^/]*\.txt$|pyproject\.toml$|Pipfile$)'; then ECO_HINT="pypi" @@ -517,17 +572,21 @@ jobs: fi fi - # --- Layer 3: Fail-loud guard (fix for issue #52) --- + # --- Layer 3: Fail-loud guard (fixes for issues #52 and #62) --- + # Two composed conditions: + # (a) Unsupported paths touched: scan cannot be proven complete (issue #62). + # (b) All touched paths supported but extraction yielded zero rows: parser + # miss on a supported file (issue #52). + # Unsupported takes message precedence — it's the more specific diagnosis. GUARD_TRIGGERED=false - if [ -z "$(echo "$DEPS_TSV" | sed '/^$/d')" ]; then - # Defense-in-depth: Layer 2 always sets TOUCHED_PATHS when it runs, but the - # ${:-} default guards against future refactors that might skip Layer 2. - TOUCHED_PATHS="${TOUCHED_PATHS:-$(echo "$DIFF" | diff_touches_lockfile 2>/dev/null || true)}" - if [ -n "$TOUCHED_PATHS" ]; then - GUARD_TRIGGERED=true - TOUCHED_LIST=$(echo "$TOUCHED_PATHS" | paste -sd, -) - EXTRACTION_WARNING="⚠️ Parser could not extract dependencies from this diff (touched: ${TOUCHED_LIST}). Manual review required before merge." - fi + if [ -n "$UNSUPPORTED_PATHS" ]; then + 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 + GUARD_TRIGGERED=true + TOUCHED_LIST=$(echo "$TOUCHED_PATHS" | paste -sd, -) + EXTRACTION_WARNING="⚠️ Parser could not extract dependencies from this diff (touched: ${TOUCHED_LIST}). Manual review required before merge." fi # Populate ACTIONS, PY_DEPS, ACTION_VERSIONS, PY_VERSIONS from DEPS_TSV @@ -890,7 +949,11 @@ jobs: # needs to agree (rather than claim "No known exploits" + auto-merge # enabled on a run we refused to scan). The EXTRACTION_WARNING_SECTION # prepended later (~line 960) still carries the detailed path list. - if [ "$GUARD_TRIGGERED" = "true" ]; then + if [ "$GUARD_TRIGGERED" = "true" ] && [ "$TOTAL" -eq 0 ]; then + # Extraction failed AND no advisories from any subset that did get scanned. + # With the issue #62 fix the guard can ALSO fire on a partial-extraction PR + # where DEPS_TSV is non-empty and scans found advisories — that case falls + # through to the TOTAL>0 branch so the actual advisory IDs are preserved. RESULTS_HEADER="Dependency extraction failed — manual review required." TABLE_HDR="| Source | Vulnerabilities |" TABLE_SEP="|--------|----------------|" @@ -903,7 +966,13 @@ jobs: TABLE_HDR="| ID | Severity | Summary | Source |" TABLE_SEP="|----|----------|---------|--------|" RESULTS_TABLE="$(printf '%s\n%s\n%s' "$TABLE_HDR" "$TABLE_SEP" "$SCAN_RESULTS")" - RESULTS_FOOTER="> Review the advisories above before deciding to merge." + if [ "$GUARD_TRIGGERED" = "true" ]; then + # Partial extraction: scans of the supported subset DID surface advisories. + # Cross-reference so the partial-coverage signal isn't lost. + RESULTS_FOOTER="> Review the advisories above before deciding to merge."$'\n'"> Note: part of the diff could not be scanned (parser does not support all touched files). Manual review required." + else + RESULTS_FOOTER="> Review the advisories above before deciding to merge." + fi elif [ -n "$HAS_ERROR" ]; then gh api "repos/${GH_REPO}/statuses/${HEAD_SHA}" \ -f state=error \ diff --git a/.github/workflows/dependency-safety.yml b/.github/workflows/dependency-safety.yml index c286e98..dae5a6f 100644 --- a/.github/workflows/dependency-safety.yml +++ b/.github/workflows/dependency-safety.yml @@ -261,6 +261,60 @@ jobs: ) # --- END inline:scripts/diff-touches-lockfile.sh --- + # --- BEGIN inline:scripts/classify-touched-paths.sh --- + classify_touched_paths() ( + # classify-touched-paths.sh — filter dependency-relevant paths down to + # those that extract-deps.sh does NOT parse. + # + # Input: newline-delimited paths on stdin (typically the output of + # diff-touches-lockfile.sh). + # Output: subset of input paths whose filename/location is NOT in the + # extract-deps.sh supported set, sorted & deduped, one per line. + # Empty stdout when every input path is supported. + # Exit: 0 on success (zero or more output rows). No exit-2 path: malformed + # input here is "an unrecognised filename" which is exactly what we + # report on stdout. + # + # Supported set (must stay in sync with scripts/extract-deps.sh): + # - .github/workflows/*.yml | *.yaml (GitHub Actions `uses:` line parser) + # - uv.lock | poetry.lock (TOML [[package]] stanza parser) + # - requirements*.txt (pip line-shape parser) + # + # `requirements*.txt` is supported by line-shape parsing, not by a full + # requirements-file parser. The extractor recognizes added requirement lines + # with operators ==, >=, <=, ~=, !=, >, or < and a numeric version prefix; + # comments, includes like `-r other.txt`, hash/check option lines, and other + # requirements-file structure are ignored. Standard Dependabot bumps reliably + # surface as added pinned requirement lines, which is what we promise. + # + # Everything else diff-touches-lockfile.sh emits — pyproject.toml, Pipfile, + # Pipfile.lock, go.mod, Cargo.toml, Cargo.lock, package.json, + # package-lock.json, yarn.lock, pnpm-lock.yaml, and any other *.lock — is + # 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. + + set -euo pipefail + + while IFS= read -r path || [ -n "$path" ]; do + [ -z "$path" ] && continue + base="${path##*/}" + + # Supported (drop from output). + case "$path" in + .github/workflows/*.yml|.github/workflows/*.yaml) continue ;; + esac + case "$base" in + uv.lock|poetry.lock) continue ;; + requirements*.txt) continue ;; + esac + + # Anything else: emit as unsupported. + printf '%s\n' "$path" + done | sort -u + ) + # --- END inline:scripts/classify-touched-paths.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. @@ -627,18 +681,19 @@ jobs: # --- Extract dependencies via standalone script (Task 8) --- DEPS_TSV=$(echo "$DIFF" | extract_deps) + # --- 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). + 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) + # --- Layer 2: PR-body fallback (defense in depth for issue #52) --- - TOUCHED_PATHS="" + # 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. if [ -z "$(echo "$DEPS_TSV" | sed '/^$/d')" ]; then - TOUCHED_PATHS=$(echo "$DIFF" | diff_touches_lockfile 2>/dev/null || true) - # Keep the pypi regex below in sync with filename_to_ecosystem() in - # scripts/extract-deps.sh — ONLY lockfiles the parser actually handles - # should route to the pypi fallback. Cargo.lock, yarn.lock, etc. are - # emitted by diff_touches_lockfile (for guard detection) but must NOT - # route here — they fall through and trip the fail-loud guard instead. - # Mixed-touch policy: if a PR touches BOTH a pypi lockfile AND an actions - # workflow YAML, pypi wins. The fallback parser handles one ecosystem per - # PR; actions bumps (if any) fall through to the guard and will refuse green. ECO_HINT="" if echo "$TOUCHED_PATHS" | grep -qE '((^|/)(uv|poetry)\.lock$|requirements[^/]*\.txt$|pyproject\.toml$|Pipfile$)'; then ECO_HINT="pypi" @@ -652,17 +707,21 @@ jobs: fi fi - # --- Layer 3: Fail-loud guard (fix for issue #52) --- + # --- Layer 3: Fail-loud guard (fixes for issues #52 and #62) --- + # Two composed conditions: + # (a) Unsupported paths touched: scan cannot be proven complete (issue #62). + # (b) All touched paths supported but extraction yielded zero rows: parser + # miss on a supported file (issue #52). + # Unsupported takes message precedence — it's the more specific diagnosis. GUARD_TRIGGERED=false - if [ -z "$(echo "$DEPS_TSV" | sed '/^$/d')" ]; then - # Defense-in-depth: Layer 2 always sets TOUCHED_PATHS when it runs, but the - # ${:-} default guards against future refactors that might skip Layer 2. - TOUCHED_PATHS="${TOUCHED_PATHS:-$(echo "$DIFF" | diff_touches_lockfile 2>/dev/null || true)}" - if [ -n "$TOUCHED_PATHS" ]; then - GUARD_TRIGGERED=true - TOUCHED_LIST=$(echo "$TOUCHED_PATHS" | paste -sd, -) - EXTRACTION_WARNING="⚠️ Parser could not extract dependencies from this diff (touched: ${TOUCHED_LIST}). Manual review required before merge." - fi + if [ -n "$UNSUPPORTED_PATHS" ]; then + 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 + GUARD_TRIGGERED=true + TOUCHED_LIST=$(echo "$TOUCHED_PATHS" | paste -sd, -) + EXTRACTION_WARNING="⚠️ Parser could not extract dependencies from this diff (touched: ${TOUCHED_LIST}). Manual review required before merge." fi # Populate ACTIONS, PY_DEPS, ACTION_VERSIONS, PY_VERSIONS from DEPS_TSV @@ -1110,8 +1169,12 @@ jobs: # shows both signals: header per priority, table reflecting actual scan data. # ---- Results table (from scan state only) ---- - if [ "$GUARD_TRIGGERED" = "true" ]; then - # Extraction failed — no scan ran. + if [ "$GUARD_TRIGGERED" = "true" ] && [ "$TOTAL" -eq 0 ]; then + # Extraction failed AND no advisories from any subset that did get scanned: + # show "(not scanned)". With the issue #62 fix the guard can ALSO fire on a + # partial-extraction PR where DEPS_TSV is non-empty and scans found + # advisories — that case falls through to the TOTAL>0 branch so the actual + # advisory IDs are preserved. TABLE_HDR="| Source | Vulnerabilities |" TABLE_SEP="|--------|----------------|" ROW1="| GitHub Advisory (GHSA) | (not scanned) |" @@ -1143,6 +1206,11 @@ jobs: if [ "$GUARD_TRIGGERED" = "true" ]; then RESULTS_HEADER="Dependency extraction failed — manual review required." RESULTS_FOOTER="> Parser could not extract dependencies. Manual review required." + if [ "$TOTAL" -gt 0 ]; then + # Partial extraction: scans of the supported subset DID surface advisories. + # Cross-reference so the IDs aren't lost under the guard header. + RESULTS_FOOTER="${RESULTS_FOOTER}"$'\n'"> Also: ${TOTAL} advisory/ies were found in the scanned subset — see table above." + fi elif [ "$HAS_SAFETY_ERROR" = "true" ]; then RESULTS_HEADER="Scan completed with errors — see workflow logs." RESULTS_FOOTER="> Scan errors prevented a clean verdict. Re-run or push to retry." diff --git a/scripts/check-inline-sync.sh b/scripts/check-inline-sync.sh index cbc8413..59ad179 100755 --- a/scripts/check-inline-sync.sh +++ b/scripts/check-inline-sync.sh @@ -15,11 +15,13 @@ INLINE_PAIRS=( ".github/workflows/dependency-cooldown.yml:scripts/check-release-age.sh" ".github/workflows/dependency-cooldown.yml:scripts/diff-touches-lockfile.sh" ".github/workflows/dependency-cooldown.yml:scripts/pr-body-to-deps.sh" + ".github/workflows/dependency-cooldown.yml:scripts/classify-touched-paths.sh" ".github/workflows/dependency-safety.yml:scripts/extract-deps.sh" ".github/workflows/dependency-safety.yml:scripts/check-release-age.sh" ".github/workflows/dependency-safety.yml:scripts/diff-touches-lockfile.sh" ".github/workflows/dependency-safety.yml:scripts/pr-body-to-deps.sh" ".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" ) diff --git a/scripts/classify-touched-paths.sh b/scripts/classify-touched-paths.sh new file mode 100755 index 0000000..4678f60 --- /dev/null +++ b/scripts/classify-touched-paths.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# classify-touched-paths.sh — filter dependency-relevant paths down to +# those that extract-deps.sh does NOT parse. +# +# Input: newline-delimited paths on stdin (typically the output of +# diff-touches-lockfile.sh). +# Output: subset of input paths whose filename/location is NOT in the +# extract-deps.sh supported set, sorted & deduped, one per line. +# Empty stdout when every input path is supported. +# Exit: 0 on success (zero or more output rows). No exit-2 path: malformed +# input here is "an unrecognised filename" which is exactly what we +# report on stdout. +# +# Supported set (must stay in sync with scripts/extract-deps.sh): +# - .github/workflows/*.yml | *.yaml (GitHub Actions `uses:` line parser) +# - uv.lock | poetry.lock (TOML [[package]] stanza parser) +# - requirements*.txt (pip line-shape parser) +# +# `requirements*.txt` is supported by line-shape parsing, not by a full +# requirements-file parser. The extractor recognizes added requirement lines +# with operators ==, >=, <=, ~=, !=, >, or < and a numeric version prefix; +# comments, includes like `-r other.txt`, hash/check option lines, and other +# requirements-file structure are ignored. Standard Dependabot bumps reliably +# surface as added pinned requirement lines, which is what we promise. +# +# Everything else diff-touches-lockfile.sh emits — pyproject.toml, Pipfile, +# Pipfile.lock, go.mod, Cargo.toml, Cargo.lock, package.json, +# package-lock.json, yarn.lock, pnpm-lock.yaml, and any other *.lock — is +# 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. + +set -euo pipefail + +while IFS= read -r path || [ -n "$path" ]; do + [ -z "$path" ] && continue + base="${path##*/}" + + # Supported (drop from output). + case "$path" in + .github/workflows/*.yml|.github/workflows/*.yaml) continue ;; + esac + case "$base" in + uv.lock|poetry.lock) continue ;; + requirements*.txt) continue ;; + esac + + # Anything else: emit as unsupported. + printf '%s\n' "$path" +done | sort -u diff --git a/tests/classify-touched-paths.bats b/tests/classify-touched-paths.bats new file mode 100644 index 0000000..556a2d4 --- /dev/null +++ b/tests/classify-touched-paths.bats @@ -0,0 +1,157 @@ +#!/usr/bin/env bats + +@test "empty input — exit 0, empty stdout" { + run bash -c 'printf "" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "single supported workflow yml — empty output" { + run bash -c 'printf ".github/workflows/ci.yml\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "single supported workflow yaml — empty output" { + run bash -c 'printf ".github/workflows/release.yaml\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "non-workflow *.yml is unsupported" { + run bash -c 'printf "mypkg/config.yml\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ "$output" = "mypkg/config.yml" ] +} + +@test "uv.lock at root — supported" { + run bash -c 'printf "uv.lock\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "uv.lock in subdir — supported (basename match)" { + run bash -c 'printf "subdir/uv.lock\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "poetry.lock — supported" { + run bash -c 'printf "poetry.lock\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "requirements.txt — supported" { + run bash -c 'printf "requirements.txt\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "requirements-dev.txt — supported (glob match)" { + run bash -c 'printf "requirements-dev.txt\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "pyproject.toml — unsupported" { + run bash -c 'printf "pyproject.toml\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ "$output" = "pyproject.toml" ] +} + +@test "Pipfile — unsupported" { + run bash -c 'printf "Pipfile\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ "$output" = "Pipfile" ] +} + +@test "Pipfile.lock — unsupported by default (no *.lock catch-all)" { + run bash -c 'printf "Pipfile.lock\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ "$output" = "Pipfile.lock" ] +} + +@test "package.json — unsupported" { + run bash -c 'printf "package.json\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ "$output" = "package.json" ] +} + +@test "package-lock.json — unsupported" { + run bash -c 'printf "package-lock.json\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ "$output" = "package-lock.json" ] +} + +@test "yarn.lock — unsupported by default (no *.lock catch-all)" { + run bash -c 'printf "yarn.lock\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ "$output" = "yarn.lock" ] +} + +@test "pnpm-lock.yaml — unsupported" { + run bash -c 'printf "pnpm-lock.yaml\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ "$output" = "pnpm-lock.yaml" ] +} + +@test "go.mod — unsupported" { + run bash -c 'printf "go.mod\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ "$output" = "go.mod" ] +} + +@test "Cargo.toml — unsupported" { + run bash -c 'printf "Cargo.toml\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ "$output" = "Cargo.toml" ] +} + +@test "Cargo.lock — unsupported" { + run bash -c 'printf "Cargo.lock\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ "$output" = "Cargo.lock" ] +} + +@test "mixed supported+unsupported (issue #62) — only unsupported emitted" { + run bash -c 'printf "uv.lock\npackage-lock.json\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ "$output" = "package-lock.json" ] +} + +@test "mixed requirements.txt + pyproject.toml — only pyproject emitted" { + run bash -c 'printf "requirements.txt\npyproject.toml\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ "$output" = "pyproject.toml" ] +} + +@test "mixed actions + Cargo.toml — only Cargo emitted" { + run bash -c 'printf ".github/workflows/ci.yml\nCargo.toml\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ "$output" = "Cargo.toml" ] +} + +@test "duplicate input — deduplicated" { + run bash -c 'printf "Cargo.toml\nCargo.toml\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ "$output" = "Cargo.toml" ] +} + +@test "output is sorted" { + run bash -c 'printf "pnpm-lock.yaml\nCargo.toml\n" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + diff <(echo "$output") <(printf 'Cargo.toml\npnpm-lock.yaml\n') +} + +@test "input without trailing newline — final record still emitted" { + run bash -c 'printf "package-lock.json" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ "$output" = "package-lock.json" ] +} + +@test "multiple records, last without trailing newline — all emitted" { + run bash -c 'printf "uv.lock\npackage-lock.json" | bash scripts/classify-touched-paths.sh' + [ "$status" -eq 0 ] + [ "$output" = "package-lock.json" ] +} diff --git a/tests/dep-guard-chain.bats b/tests/dep-guard-chain.bats new file mode 100644 index 0000000..c98cfd9 --- /dev/null +++ b/tests/dep-guard-chain.bats @@ -0,0 +1,65 @@ +#!/usr/bin/env bats +# dep-guard-chain.bats — integration tests for the Layer 2/3 guard composition. +# +# These tests drive the real helper chain (extract-deps + diff-touches-lockfile +# + classify-touched-paths) against canned diff fixtures and assert the values +# the workflow guard keys on: DEPS_TSV (extracted dep rows), TOUCHED_PATHS +# (dependency-relevant files in the diff), and UNSUPPORTED_PATHS (the subset +# extract-deps cannot parse). +# +# Workflow YAML execution is not exercised here; tests/guard-shape.bats guards +# the YAML control flow statically. + +setup() { + FIXTURES="tests/fixtures/dep-guard-chain" +} + +# Helper: run the chain on a fixture file and export the three intermediate +# values into the test's environment. +run_chain() { + local fixture="$1" + DEPS_TSV=$(bash scripts/extract-deps.sh < "$fixture" || true) + TOUCHED_PATHS=$(bash scripts/diff-touches-lockfile.sh < "$fixture" 2>/dev/null || true) + UNSUPPORTED_PATHS=$(printf '%s\n' "$TOUCHED_PATHS" | bash scripts/classify-touched-paths.sh 2>/dev/null || true) +} + +@test "issue #62 repro: uv.lock + package-lock.json — DEPS_TSV non-empty, UNSUPPORTED contains package-lock.json" { + run_chain "$FIXTURES/uv-and-package-lock.diff" + # DEPS_TSV has the uv.lock row (mypy 1.20.1 pypi). + [ -n "$DEPS_TSV" ] + [[ "$DEPS_TSV" == *"mypy"* ]] + # TOUCHED_PATHS includes both files. + [[ "$TOUCHED_PATHS" == *"uv.lock"* ]] + [[ "$TOUCHED_PATHS" == *"package-lock.json"* ]] + # UNSUPPORTED_PATHS contains ONLY package-lock.json. + [ "$UNSUPPORTED_PATHS" = "package-lock.json" ] +} + +@test "issue #52 preserved: uv.lock parser miss — DEPS_TSV empty, TOUCHED=uv.lock, UNSUPPORTED empty" { + run_chain "$FIXTURES/uv-lock-parser-miss.diff" + [ -z "$DEPS_TSV" ] + [ "$TOUCHED_PATHS" = "uv.lock" ] + [ -z "$UNSUPPORTED_PATHS" ] +} + +@test "requirements.txt standard bump — DEPS_TSV non-empty, UNSUPPORTED empty" { + run_chain "$FIXTURES/requirements-bump.diff" + [ -n "$DEPS_TSV" ] + [[ "$DEPS_TSV" == *"requests"* ]] + [ "$TOUCHED_PATHS" = "requirements.txt" ] + [ -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" ] +} + +@test "workflow YAML uses: bump — DEPS_TSV non-empty, UNSUPPORTED empty" { + run_chain "$FIXTURES/workflow-uses-bump.diff" + [ -n "$DEPS_TSV" ] + [[ "$DEPS_TSV" == *"actions/checkout"* ]] + [ "$TOUCHED_PATHS" = ".github/workflows/ci.yml" ] + [ -z "$UNSUPPORTED_PATHS" ] +} diff --git a/tests/fixtures/dep-guard-chain/pyproject-only.diff b/tests/fixtures/dep-guard-chain/pyproject-only.diff new file mode 100644 index 0000000..9d5b7fe --- /dev/null +++ b/tests/fixtures/dep-guard-chain/pyproject-only.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/dep-guard-chain/requirements-bump.diff b/tests/fixtures/dep-guard-chain/requirements-bump.diff new file mode 100644 index 0000000..b12938c --- /dev/null +++ b/tests/fixtures/dep-guard-chain/requirements-bump.diff @@ -0,0 +1,8 @@ +diff --git a/requirements.txt b/requirements.txt +index 1111111..2222222 100644 +--- a/requirements.txt ++++ b/requirements.txt +@@ -1,2 +1,2 @@ +-requests==2.31.0 ++requests==2.32.0 + click==8.1.0 diff --git a/tests/fixtures/dep-guard-chain/uv-and-package-lock.diff b/tests/fixtures/dep-guard-chain/uv-and-package-lock.diff new file mode 100644 index 0000000..1b2f479 --- /dev/null +++ b/tests/fixtures/dep-guard-chain/uv-and-package-lock.diff @@ -0,0 +1,19 @@ +diff --git a/uv.lock b/uv.lock +index 1111111..2222222 100644 +--- a/uv.lock ++++ b/uv.lock +@@ -100,7 +100,7 @@ + [[package]] + name = "mypy" +-version = "1.20.0" ++version = "1.20.1" + source = { registry = "https://pypi.org/simple" } +diff --git a/package-lock.json b/package-lock.json +index 3333333..4444444 100644 +--- a/package-lock.json ++++ b/package-lock.json +@@ -1,3 +1,3 @@ + { +- "lockfileVersion": 2 ++ "lockfileVersion": 3 + } diff --git a/tests/fixtures/dep-guard-chain/uv-lock-parser-miss.diff b/tests/fixtures/dep-guard-chain/uv-lock-parser-miss.diff new file mode 100644 index 0000000..668ebb2 --- /dev/null +++ b/tests/fixtures/dep-guard-chain/uv-lock-parser-miss.diff @@ -0,0 +1,10 @@ +diff --git a/uv.lock b/uv.lock +index 1111111..2222222 100644 +--- a/uv.lock ++++ b/uv.lock +@@ -1,4 +1,4 @@ +-# This file was autogenerated by uv v0.4.0 ++# This file was autogenerated by uv v0.4.1 + version = 1 + requires-python = ">=3.11" + resolution-markers = [] diff --git a/tests/fixtures/dep-guard-chain/workflow-uses-bump.diff b/tests/fixtures/dep-guard-chain/workflow-uses-bump.diff new file mode 100644 index 0000000..119f639 --- /dev/null +++ b/tests/fixtures/dep-guard-chain/workflow-uses-bump.diff @@ -0,0 +1,10 @@ +diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml +index 1111111..2222222 100644 +--- a/.github/workflows/ci.yml ++++ b/.github/workflows/ci.yml +@@ -10,7 +10,7 @@ jobs: + build: + runs-on: ubuntu-latest + steps: +- - uses: actions/checkout@v4.1.0 ++ - uses: actions/checkout@v4.2.0 diff --git a/tests/guard-runtime.bats b/tests/guard-runtime.bats new file mode 100644 index 0000000..fc7fe92 --- /dev/null +++ b/tests/guard-runtime.bats @@ -0,0 +1,98 @@ +#!/usr/bin/env bats +# guard-runtime.bats — execute each workflow's Layer 3 guard block against +# representative input combinations and assert the GUARD_TRIGGERED outcome. +# +# Why: tests/guard-shape.bats only checks LINE ORDERING. If someone wraps the +# UNSUPPORTED_PATHS branch back under an outer `if [ -z "$DEPS_TSV" ]; then`, +# the line ordering is unchanged and shape tests pass — but the issue #62 +# silent-green path returns. These tests source the actual block as bash +# and verify behaviour with non-empty DEPS_TSV + non-empty UNSUPPORTED_PATHS, +# which is exactly the case the outer-if regression would mishandle. + +# Extract from the Layer 3 comment marker through the first `fi` line that +# starts at exactly 10 leading spaces (the `run: |` indent). Strip those 10 +# spaces so the result is plain bash. If a future refactor wraps the inner +# if/elif in an outer `if [ ... ]; then ... fi` (also at 10 spaces), the +# OUTER fi terminates the capture and is included — that's intentional, and +# the eval'd block then no longer triggers under the issue #62 inputs. +extract_guard_block() { + local yaml="$1" + awk '/^[[:space:]]+# --- Layer 3:/{flag=1} flag {print} flag && /^ fi[[:space:]]*$/{exit}' "$yaml" \ + | sed -E 's/^ //' +} + +@test "cooldown: issue #62 case (DEPS_TSV non-empty + UNSUPPORTED non-empty) — guard fires" { + block=$(extract_guard_block .github/workflows/dependency-cooldown.yml) + DEPS_TSV=$'mypy\t1.20.1\tpypi' + TOUCHED_PATHS=$'package-lock.json\nuv.lock' + UNSUPPORTED_PATHS="package-lock.json" + eval "$block" + [ "$GUARD_TRIGGERED" = "true" ] + [[ "$EXTRACTION_WARNING" == *"does not support"* ]] +} + +@test "safety: issue #62 case (DEPS_TSV non-empty + UNSUPPORTED non-empty) — guard fires" { + block=$(extract_guard_block .github/workflows/dependency-safety.yml) + DEPS_TSV=$'mypy\t1.20.1\tpypi' + TOUCHED_PATHS=$'package-lock.json\nuv.lock' + UNSUPPORTED_PATHS="package-lock.json" + eval "$block" + [ "$GUARD_TRIGGERED" = "true" ] + [[ "$EXTRACTION_WARNING" == *"does not support"* ]] +} + +@test "cooldown: issue #52 case (DEPS_TSV empty + TOUCHED non-empty + UNSUPPORTED empty) — guard fires" { + block=$(extract_guard_block .github/workflows/dependency-cooldown.yml) + DEPS_TSV="" + TOUCHED_PATHS="uv.lock" + UNSUPPORTED_PATHS="" + eval "$block" + [ "$GUARD_TRIGGERED" = "true" ] + [[ "$EXTRACTION_WARNING" == *"Parser could not extract"* ]] +} + +@test "safety: issue #52 case (DEPS_TSV empty + TOUCHED non-empty + UNSUPPORTED empty) — guard fires" { + block=$(extract_guard_block .github/workflows/dependency-safety.yml) + DEPS_TSV="" + TOUCHED_PATHS="uv.lock" + UNSUPPORTED_PATHS="" + eval "$block" + [ "$GUARD_TRIGGERED" = "true" ] + [[ "$EXTRACTION_WARNING" == *"Parser could not extract"* ]] +} + +@test "cooldown: clean supported case (DEPS_TSV non-empty + UNSUPPORTED empty) — guard does NOT fire" { + block=$(extract_guard_block .github/workflows/dependency-cooldown.yml) + DEPS_TSV=$'requests\t2.32.0\tpypi' + TOUCHED_PATHS="requirements.txt" + UNSUPPORTED_PATHS="" + eval "$block" + [ "$GUARD_TRIGGERED" = "false" ] +} + +@test "safety: clean supported case (DEPS_TSV non-empty + UNSUPPORTED empty) — guard does NOT fire" { + block=$(extract_guard_block .github/workflows/dependency-safety.yml) + DEPS_TSV=$'requests\t2.32.0\tpypi' + TOUCHED_PATHS="requirements.txt" + UNSUPPORTED_PATHS="" + eval "$block" + [ "$GUARD_TRIGGERED" = "false" ] +} + +@test "cooldown: no touched paths (empty diff) — guard does NOT fire" { + block=$(extract_guard_block .github/workflows/dependency-cooldown.yml) + DEPS_TSV="" + TOUCHED_PATHS="" + UNSUPPORTED_PATHS="" + eval "$block" + [ "$GUARD_TRIGGERED" = "false" ] +} + +@test "safety: no touched paths (empty diff) — guard does NOT fire" { + block=$(extract_guard_block .github/workflows/dependency-safety.yml) + DEPS_TSV="" + TOUCHED_PATHS="" + UNSUPPORTED_PATHS="" + eval "$block" + [ "$GUARD_TRIGGERED" = "false" ] +} diff --git a/tests/guard-shape.bats b/tests/guard-shape.bats new file mode 100644 index 0000000..f79e4a4 --- /dev/null +++ b/tests/guard-shape.bats @@ -0,0 +1,56 @@ +#!/usr/bin/env bats +# guard-shape.bats — static assertions that both safety workflows compose the +# Layer 3 guard in the required order: UNSUPPORTED_PATHS branch first +# (issue #62), then the zero-rows fallback branch (issue #52). +# +# Why: the composed Layer 3 logic lives in workflow YAML and is not directly +# unit-testable; this guard prevents accidental ordering regressions. + +WORKFLOWS=( + ".github/workflows/dependency-cooldown.yml" + ".github/workflows/dependency-safety.yml" +) + +@test "dependency-cooldown.yml: TOUCHED_PATHS/UNSUPPORTED_PATHS hoisted above Layer 2" { + yaml=".github/workflows/dependency-cooldown.yml" + hoist_line=$(grep -n "UNSUPPORTED_PATHS=\$(printf" "$yaml" | head -1 | cut -d: -f1) + layer2_line=$(grep -n "Layer 2: PR-body fallback" "$yaml" | head -1 | cut -d: -f1) + [ -n "$hoist_line" ] + [ -n "$layer2_line" ] + [ "$hoist_line" -lt "$layer2_line" ] +} + +@test "dependency-safety.yml: TOUCHED_PATHS/UNSUPPORTED_PATHS hoisted above Layer 2" { + yaml=".github/workflows/dependency-safety.yml" + hoist_line=$(grep -n "UNSUPPORTED_PATHS=\$(printf" "$yaml" | head -1 | cut -d: -f1) + layer2_line=$(grep -n "Layer 2: PR-body fallback" "$yaml" | head -1 | cut -d: -f1) + [ -n "$hoist_line" ] + [ -n "$layer2_line" ] + [ "$hoist_line" -lt "$layer2_line" ] +} + +@test "dependency-cooldown.yml: Layer 3 guard checks UNSUPPORTED_PATHS before zero-rows elif" { + yaml=".github/workflows/dependency-cooldown.yml" + unsupported_line=$(grep -n 'if \[ -n "\$UNSUPPORTED_PATHS" \]; then' "$yaml" | head -1 | cut -d: -f1) + elif_line=$(grep -n 'elif \[ -z "\$(echo "\$DEPS_TSV" | sed' "$yaml" | head -1 | cut -d: -f1) + [ -n "$unsupported_line" ] + [ -n "$elif_line" ] + [ "$unsupported_line" -lt "$elif_line" ] +} + +@test "dependency-safety.yml: Layer 3 guard checks UNSUPPORTED_PATHS before zero-rows elif" { + yaml=".github/workflows/dependency-safety.yml" + unsupported_line=$(grep -n 'if \[ -n "\$UNSUPPORTED_PATHS" \]; then' "$yaml" | head -1 | cut -d: -f1) + elif_line=$(grep -n 'elif \[ -z "\$(echo "\$DEPS_TSV" | sed' "$yaml" | head -1 | cut -d: -f1) + [ -n "$unsupported_line" ] + [ -n "$elif_line" ] + [ "$unsupported_line" -lt "$elif_line" ] +} + +@test "both workflows: classify_touched_paths is called (not just defined)" { + for yaml in "${WORKFLOWS[@]}"; do + # 2 occurrences: 1 inline function definition, 1 call site. + count=$(grep -c "classify_touched_paths" "$yaml") + [ "$count" -ge 2 ] || { echo "FAIL: $yaml has only $count classify_touched_paths references"; return 1; } + done +}