diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index 83f6830d5..60c5bfc66 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -820,6 +820,7 @@ jobs: summary_output_file="${RUNNER_TEMP}/coverage-evidence-output.md" failures=0 r_peer_check_required=0 + python_native_peer_check_required=0 append() { printf '%s\n' "$*" >>"$summary_file" @@ -898,6 +899,104 @@ jobs: rm -f "$log_file" } + run_python_native_extension_classifier() { + local project_dir="$1" + local python_native_pytest_log="$2" + local python_native_changed_files="$3" + local python_native_pyproject_snapshot="$4" + + [ -s "$python_native_pyproject_snapshot" ] || return 1 + python3 -I "$GITHUB_WORKSPACE/scripts/ci/python_native_extension_peer_gate.py" \ + classify-pytest \ + --log "$python_native_pytest_log" \ + --pyproject "$python_native_pyproject_snapshot" \ + --logical-pyproject "$project_dir/pyproject.toml" \ + --changed-files "$python_native_changed_files" \ + --repo-root "$COVERAGE_SOURCE_WORKDIR" + } + + run_python_test_and_capture() { + local label="$1" + local project_dir="$2" + shift 2 + local python_native_pytest_log + local python_native_changed_files + local python_native_pyproject_snapshot + local rc + + python_native_pytest_log="$(mktemp)" + python_native_changed_files="$(mktemp)" + python_native_pyproject_snapshot="$(mktemp)" + changed_files_for_coverage >"$python_native_changed_files" + chmod 0444 "$python_native_changed_files" + if [ -f "$project_dir/pyproject.toml" ] \ + && [ ! -L "$project_dir/pyproject.toml" ]; then + install -m 0444 -- \ + "$project_dir/pyproject.toml" \ + "$python_native_pyproject_snapshot" + fi + + append "### ${label}" + append "" + append '```text' + append_command "$@" + set +e + timeout --kill-after=20 900 setpriv \ + --reuid "$OPENCODE_SANDBOX_UID" \ + --regid "$OPENCODE_SANDBOX_GID" \ + --clear-groups \ + env \ + -u ACTIONS_ID_TOKEN_REQUEST_TOKEN \ + -u ACTIONS_ID_TOKEN_REQUEST_URL \ + -u ACTIONS_RUNTIME_TOKEN \ + -u GH_TOKEN \ + -u GITHUB_TOKEN \ + GITHUB_ENV=/dev/null \ + GITHUB_PATH=/dev/null \ + GITHUB_OUTPUT=/dev/null \ + GITHUB_STEP_SUMMARY=/dev/null \ + BASH_ENV=/dev/null \ + UV_NO_BUILD=1 \ + GIT_CONFIG_NOSYSTEM=1 \ + GIT_CONFIG_GLOBAL=/dev/null \ + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=safe.directory \ + GIT_CONFIG_VALUE_0=/work \ + HOME=/work/.opencode-sandbox-home \ + XDG_CACHE_HOME=/work/.opencode-sandbox-cache \ + CARGO_HOME=/work/.opencode-sandbox-home/.cargo \ + PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ + "$@" >"$python_native_pytest_log" 2>&1 + rc=$? + set -e + emit_captured_log "$python_native_pytest_log" + append '```' + append "" + + if [ "$rc" -eq 0 ]; then + append "- Result: PASS" + elif run_python_native_extension_classifier \ + "$project_dir" \ + "$python_native_pytest_log" \ + "$python_native_changed_files" \ + "$python_native_pyproject_snapshot" >/dev/null 2>&1; then + append "### Python native-extension source-only deferral" + append "" + append "- Result: DEFERRED" + append "- Reason: the unchanged declared PyO3 module was unavailable in the source-only sandbox; exact-head Python, Rust/PyO3, and package CheckRuns must all complete successfully before approval." + append "" + python_native_peer_check_required=1 + else + append "- Result: FAIL (exit ${rc})" + failures=$((failures + 1)) + fi + append "" + rm -f \ + "$python_native_pytest_log" \ + "$python_native_changed_files" \ + "$python_native_pyproject_snapshot" + } + run_r_package_testthat() { local package_name="$1" local log_file rc classification description_snapshot @@ -1095,13 +1194,15 @@ jobs: if [ -n "$configured_commands_json" ]; then while IFS= read -r configured_command_json; do [ -n "$configured_command_json" ] || continue - run_and_capture "Python configured CI test suite (${project_dir})" \ + run_python_test_and_capture \ + "Python configured CI test suite (${project_dir})" "$project_dir" \ python3 "${GITHUB_WORKSPACE}/scripts/ci/safe_pytest_command.py" execute \ --project-dir "$project_dir" \ --command-json "$configured_command_json" done <<<"$configured_commands_json" else - run_and_capture "Python coverage with missing-line report (${project_dir})" \ + run_python_test_and_capture \ + "Python coverage with missing-line report (${project_dir})" "$project_dir" \ bash -c 'cd "$1" && PYTHONPATH="$([ -d src ] && printf src:. || printf .)" python3 -m coverage run -m pytest tests && python3 -m coverage report --show-missing' bash "$project_dir" fi done < <(tracked_python_projects_with_tests) @@ -1992,6 +2093,9 @@ jobs: if [ "$r_peer_check_required" -eq 1 ]; then append "- R test evidence: deferred package-load failures require a successful current-head peer R CMD check" fi + if [ "$python_native_peer_check_required" -eq 1 ]; then + append "- Python native-extension peer evidence: deferred source-only collection requires successful exact-head peer checks" + fi fi else append "- Result: FAIL" @@ -7120,6 +7224,103 @@ jobs: return 2 } + coverage_defers_to_python_native_peer_checks() { + printf '%s\n' "${COVERAGE_EVIDENCE_SUMMARY:-}" | + grep -Fq -- "- Python native-extension peer evidence: deferred source-only collection requires successful exact-head peer checks" + } + + collect_successful_python_native_peer_check_evidence() { + local output_file="$1" + local owner="${GH_REPOSITORY%%/*}" + local name="${GH_REPOSITORY#*/}" + local graphql_file + graphql_file="$(mktemp)" + + # Materialize trusted current-head GraphQL check-runs as the helper's + # bounded JSON contract. A stale PR head yields an empty list. + if ! timeout "$(check_lookup_api_timeout_seconds)s" gh api graphql \ + -f owner="$owner" \ + -f name="$name" \ + -F number="$PR_NUMBER" \ + -f query=' + query($owner:String!,$name:String!,$number:Int!) { + repository(owner:$owner,name:$name) { + pullRequest(number:$number) { + headRefOid + statusCheckRollup { + contexts(first: 100) { + nodes { + __typename + ... on CheckRun { + name + status + conclusion + checkSuite { + workflowRun { + workflow { + name + } + } + } + } + } + } + } + } + } + } + ' >"$graphql_file"; then + rm -f "$graphql_file" + return 1 + fi + + jq --arg head_sha "$PR_HEAD_SHA" ' + .data.repository.pullRequest as $pr + | if (($pr.headRefOid // "") != $head_sha) then + [] + else + [ + ($pr.statusCheckRollup.contexts.nodes // [])[] + | select(.__typename == "CheckRun") + | { + __typename: "CheckRun", + workflow: (.checkSuite.workflowRun.workflow.name // ""), + name: (.name // ""), + head_sha: $head_sha, + status: (.status // ""), + conclusion: (.conclusion // "") + } + ] + end + ' "$graphql_file" >"$output_file" + rm -f "$graphql_file" + + python3 "$GITHUB_WORKSPACE/scripts/ci/python_native_extension_peer_gate.py" \ + require-checks \ + --checks-json "$output_file" \ + --head-sha "$PR_HEAD_SHA" \ + --required-check "CI::python" \ + --required-check "CI::rust" \ + --required-check "CI::package" >/dev/null + } + + require_python_native_peer_checks_for_deferred_coverage() { + local checks_file + if ! coverage_defers_to_python_native_peer_checks; then + return 0 + fi + checks_file="$(mktemp)" + if collect_github_checks_with_retry \ + collect_successful_python_native_peer_check_evidence "$checks_file"; then + rm -f "$checks_file" + printf 'Verified successful exact-head Python, Rust/PyO3, and package CheckRuns after bounded source-only native-extension deferral.\n' + return 0 + fi + rm -f "$checks_file" + printf '::notice::Python native-extension source-only deferral cannot authorize approval without successful exact-head Python, Rust/PyO3, and package CheckRuns.\n' + return 1 + } + coverage_defers_to_r_cmd_check() { printf '%s\n' "${COVERAGE_EVIDENCE_SUMMARY:-}" | grep -Fq -- "- R test evidence: deferred package-load failures require a successful current-head peer R CMD check" @@ -7270,6 +7471,10 @@ jobs: return 0 fi + if ! require_python_native_peer_checks_for_deferred_coverage; then + return 1 + fi + if ! require_r_cmd_check_for_deferred_coverage; then return 1 fi @@ -7446,6 +7651,12 @@ jobs: request_changes_for_coverage_evidence_failure fi + if ! require_python_native_peer_checks_for_deferred_coverage; then + printf '::error::Python native-extension source-only coverage deferral is missing successful exact-head Python, Rust/PyO3, or package CheckRun evidence for head %s.\n' "$PR_HEAD_SHA" + echo "::endgroup::" + exit 1 + fi + opencode_review_outcome="${OPENCODE_MODEL_POOL_OUTCOME:-unknown}" printf 'OpenCode model-pool outcome=%s model=%s; publish stage performs no duplicate model-catalog pass.\n' \ "$opencode_review_outcome" "${OPENCODE_MODEL_POOL_MODEL:-none}" @@ -7658,6 +7869,28 @@ jobs: stop_failed_check_fallback_unavailable fi fi + if ! require_python_native_peer_checks_for_deferred_coverage; then + body="$(printf '%s\n' \ + "## Pull request overview" \ + "" \ + "OpenCode reviewed the current-head source evidence but Python native-extension tests were deferred after a bounded source-only collection failure." \ + "" \ + "## Approval hold" \ + "" \ + "### Successful exact-head Python, Rust/PyO3, and package checks are required" \ + "- Problem: coverage-evidence deferred an unchanged PyO3 module import, but the three trusted peer checks were not all successful on the exact current head." \ + "- Root cause: source-only deferral is safe only when the repository's Python, native Rust/PyO3, and package workflows all pass on this exact head." \ + "- Fix: add or repair the trusted CI::python, CI::rust, and CI::package checks and rerun them on the current head." \ + "- Regression test: Keep native-extension coverage fail-closed unless all required exact-head CheckRuns pass." \ + "" \ + "- Result: WAITING_FOR_PYTHON_NATIVE_PEER_CHECKS" \ + "- Head SHA: \`${HEAD_SHA}\`" \ + "- Workflow run: ${RUN_ID}" \ + "- Workflow attempt: ${RUN_ATTEMPT}" + )" + hold_approval_without_review "WAITING_FOR_PYTHON_NATIVE_PEER_CHECKS" "$body" + fi + if ! require_r_cmd_check_for_deferred_coverage; then body="$(printf '%s\n' \ "## Pull request overview" \ diff --git a/.github/workflows/python-native-extension-peer-gate-quality-ci.yml b/.github/workflows/python-native-extension-peer-gate-quality-ci.yml new file mode 100644 index 000000000..d0fd8caae --- /dev/null +++ b/.github/workflows/python-native-extension-peer-gate-quality-ci.yml @@ -0,0 +1,192 @@ +name: Python Native Extension Peer Gate Quality CI + +on: + pull_request: + branches: [main] + paths: + - ".github/workflows/opencode-review-dispatch.yml" + - ".github/workflows/python-native-extension-peer-gate-quality-ci.yml" + - "scripts/ci/python_native_extension_peer_gate.py" + - "tests/test_python_native_extension_peer_gate.py" + - "tests/test_python_native_extension_peer_gate_nested_project.py" + - "tests/test_python_native_extension_peer_gate_file_safety.py" + - "tests/test_python_native_extension_peer_gate_requirements_directory.py" + - "tests/test_python_native_extension_peer_gate_workflow_contract.py" + - "docs/doctoring/python-native-extension-peer-evidence.md" + - "requirements-opencode-review-ci-hashes.txt" + - "CHANGELOG.md" + push: + branches: [main] + paths: + - ".github/workflows/opencode-review-dispatch.yml" + - ".github/workflows/python-native-extension-peer-gate-quality-ci.yml" + - "scripts/ci/python_native_extension_peer_gate.py" + - "tests/test_python_native_extension_peer_gate.py" + - "tests/test_python_native_extension_peer_gate_nested_project.py" + - "tests/test_python_native_extension_peer_gate_file_safety.py" + - "tests/test_python_native_extension_peer_gate_requirements_directory.py" + - "tests/test_python_native_extension_peer_gate_workflow_contract.py" + - "docs/doctoring/python-native-extension-peer-evidence.md" + - "requirements-opencode-review-ci-hashes.txt" + - "CHANGELOG.md" + +concurrency: + group: python-native-extension-peer-gate-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + minimum-python-contract: + name: Python 3.10 compatibility contract + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact source revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Set up minimum supported Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.10" + + - name: Compile production and tests on Python 3.10 + run: | + python -m compileall -q \ + scripts/ci/python_native_extension_peer_gate.py \ + tests/test_python_native_extension_peer_gate.py \ + tests/test_python_native_extension_peer_gate_nested_project.py \ + tests/test_python_native_extension_peer_gate_file_safety.py \ + tests/test_python_native_extension_peer_gate_requirements_directory.py \ + tests/test_python_native_extension_peer_gate_workflow_contract.py + + - name: Exercise the conditional tomli import + run: | + python - <<'PY' + import sys + import tempfile + from pathlib import Path + + stub_root = Path(tempfile.mkdtemp(prefix="pyo3-peer-gate-tomli-stub-")) + (stub_root / "tomli.py").write_text( + "class TOMLDecodeError(ValueError):\n" + " pass\n" + "def loads(_value):\n" + " return {}\n", + encoding="utf-8", + ) + sys.path.insert(0, str(stub_root)) + from scripts.ci import python_native_extension_peer_gate as gate + + assert gate.tomllib.__name__ == "tomli" + PY + + full-quality-gate: + name: Python 3.14 full quality gate + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact source revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Set up current stable Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + + - name: Install hash-locked quality tooling + run: >- + python -m pip install --disable-pip-version-check --require-hashes + -r requirements-opencode-review-ci-hashes.txt + + - name: Run focused peer-gate tests with complete branch coverage + run: | + cat >"${RUNNER_TEMP}/python-native-peer-coveragerc" <<'EOF' + [run] + branch = True + include = + scripts/ci/python_native_extension_peer_gate.py + + [report] + fail_under = 100 + show_missing = True + EOF + export COVERAGE_RCFILE="${RUNNER_TEMP}/python-native-peer-coveragerc" + python -m coverage erase + python -m coverage run -m pytest \ + tests/test_python_native_extension_peer_gate.py \ + tests/test_python_native_extension_peer_gate_nested_project.py \ + tests/test_python_native_extension_peer_gate_file_safety.py \ + tests/test_python_native_extension_peer_gate_requirements_directory.py \ + tests/test_python_native_extension_peer_gate_workflow_contract.py \ + -q + python -m coverage report + + - name: Run complete central test and branch coverage gate + run: | + unset COVERAGE_RCFILE + python -m coverage erase + python -m coverage run -m pytest tests -q + python -m coverage report + + - name: Enforce complete production docstrings + run: >- + python -m interrogate --fail-under 100 + scripts/ci/python_native_extension_peer_gate.py + + - name: Compile production and quality contracts + run: | + python -m compileall -q \ + scripts/ci/python_native_extension_peer_gate.py \ + tests/test_python_native_extension_peer_gate.py \ + tests/test_python_native_extension_peer_gate_nested_project.py \ + tests/test_python_native_extension_peer_gate_file_safety.py \ + tests/test_python_native_extension_peer_gate_requirements_directory.py \ + tests/test_python_native_extension_peer_gate_workflow_contract.py + + - name: Install checksum-pinned actionlint + env: + ACTIONLINT_VERSION: "1.7.12" + ACTIONLINT_SHA256: "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" + run: | + set -euo pipefail + archive="${RUNNER_TEMP}/actionlint.tar.gz" + curl --fail --location --proto '=https' --tlsv1.2 \ + --output "$archive" \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + printf '%s %s\n' "$ACTIONLINT_SHA256" "$archive" | sha256sum --check --strict + tar --extract --gzip --file "$archive" --directory "$RUNNER_TEMP" actionlint + test -x "${RUNNER_TEMP}/actionlint" + + - name: Validate protected workflow syntax with actionlint + run: | + # Shell behavior is covered by the repository's executable contract + # tests; keep this bounded gate focused on YAML and expressions. + "${RUNNER_TEMP}/actionlint" -shellcheck= \ + .github/workflows/opencode-review-dispatch.yml \ + .github/workflows/python-native-extension-peer-gate-quality-ci.yml + + - name: Verify clean patches + run: git diff --check diff --git a/CHANGELOG.md b/CHANGELOG.md index bf30091dd..7996472ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Semantic Versioning where the repository publishes a release. - Added a trusted pull-request comment router for `@cwl-noema-review` and review-only `@opencode-agent` dispatches, with an organization sweep, exact-head receipts, repository allowlisting, fixed runners, immutable checkout pins, and a permanent 100% statement/branch/docstring quality gate. - Added exact-base `uv.lock` materialization that reconstructs standalone nested projects with a checksum-pinned official `uv` exporter, isolated frozen/offline execution, strict exact-pin and SHA-256 output validation, and complete Python 3.10/3.14 quality evidence. +- Added a bounded PyO3/maturin pytest-failure classifier and exact-head native peer-check verifier so source-only OpenCode sandboxes can distinguish one unchanged-extension collection limitation from product failures without skipping tests, executing pull-request build hooks, or weakening Rust ownership. ### Fixed diff --git a/docs/doctoring/python-native-extension-peer-evidence.md b/docs/doctoring/python-native-extension-peer-evidence.md new file mode 100644 index 000000000..3696d68fb --- /dev/null +++ b/docs/doctoring/python-native-extension-peer-evidence.md @@ -0,0 +1,181 @@ +# Doctoring record: Python native-extension peer evidence + +## Purpose + +The central OpenCode coverage sandbox executes pull-request tests without a +repository credential, package-index access, or permission to run +pull-request-selected build/install hooks. That isolation is intentional, but a +mixed Rust/Python project can require a compiled PyO3 extension during pytest +collection. A plain source checkout then raises `ModuleNotFoundError` before any +Python test is collected even when the exact pull-request head has already built, +installed, and tested the extension in trusted repository jobs. + +This record defines a bounded classifier and exact-head peer-evidence gate. The +classifier does **not** convert a missing extension into passing test evidence. +It can only identify one narrow execution-environment limitation and defer the +final decision to separately successful native build and test checks on the same +commit. + +## Observed failure + +`ContextualWisdomLab/fast-mlsirm#546` uses the maturin mixed-project layout: + +```toml +[build-system] +build-backend = "maturin" + +[tool.maturin] +bindings = "pyo3" +manifest-path = "crates/fast-mlsirm-py/Cargo.toml" +module-name = "fast_mlsirm._core" +python-source = "python" +``` + +The repository CI first builds and installs the native module and then runs +pytest. The isolated central source sandbox deliberately does not perform that +build, so collection stops at: + +```text +ModuleNotFoundError: No module named 'fast_mlsirm._core' +``` + +Maturin documents that `module-name` places the compiled extension inside the +configured Python source tree and that `maturin develop` or an installation step +materializes the shared library. PyO3 likewise documents that a native module +must be compiled and exposed with the matching module name before Python can +import it. The source checkout alone is therefore not equivalent to the +installed package. + +## Classification contract + +`scripts/ci/python_native_extension_peer_gate.py classify-pytest` accepts a +failure only when every condition below is true: + +1. `pyproject.toml` is a bounded, regular, non-symlink UTF-8 file. +2. The build backend is exactly `maturin` and bindings are exactly `pyo3`. +3. `module-name`, `manifest-path`, and `python-source` are safe relative values. +4. The pytest log is bounded, complete, and contains only collection errors. +5. Every terminal exception is `ModuleNotFoundError` for the declared module. +6. Every collection-error block contains a direct import of that module. +7. The interruption count, collection-block count, and missing-module count + agree exactly. +8. There is no failure, setup/teardown error, internal pytest error, crash, + segmentation fault, or truncation marker. +9. The changed-file list is bounded, unique, and traversal-free. +10. The pull request does not change Rust source, Cargo metadata, native stubs, + maturin metadata, dependency locks, requirements, packaging files, GitHub + workflows/actions, or any file under the native crate directory. + +The workflow seals `pyproject.toml` into a root-owned, read-only snapshot before +running pull-request-selected tests. Classification parses that snapshot while +using the separately supplied logical project path only for repository-relative +trust-boundary comparison. A test process therefore cannot rewrite project +metadata and manufacture a deferral after execution begins. + +A rejected classification remains an ordinary blocking test failure. + +## Exact-head peer evidence + +A successful classification is not approval. Before the central workflow may +accept it, `require-checks` must receive normalized `CheckRun` records for the +exact 40-character pull-request head and prove all trusted requirements. The +initial `fast-mlsirm` contract requires: + +```text +CI::python +CI::rust +CI::package +``` + +Every matching check must be a GitHub `CheckRun`, belong to the trusted workflow, +carry the exact head SHA, have status `COMPLETED`, and conclusion `SUCCESS`. +Missing, pending, failed, cancelled, neutral, skipped-required, stale-head, +status-only, or lookalike check records fail closed. The workflow and check names +must be supplied by trusted central or protected-base configuration, not by pull +request prose. + +GPU and fuzz evidence remain independent repository gates. The peer gate neither +removes nor reinterprets them. + +## Change-sensitive boundary + +The deferral exists only for an unchanged native/package trust boundary. Any +change to the extension implementation, Cargo manifests or lock, maturin +configuration, native stub, packaging metadata, dependency locks, or CI workflow +requires a direct trusted native build path. This prevents a pull request from +changing the thing being imported while asking the central sandbox to trust an +older binary or a weakly named passing check. + +Python business or reporting code and its tests may use the deferral when the +native boundary is unchanged, but the current-head repository Python job must +still execute the complete suite against the built extension. + +## Security and privacy boundary + +The helper reads only bounded regular files and performs no network access, +subprocess execution, package installation, token access, or mutation. It does +not load the target project as Python code. TOML and JSON are parsed as data. +Repository paths reject absolute paths, parent traversal, current-directory +aliases, Windows separators, NUL, and duplicates. + +The classifier does not make arbitrary `ModuleNotFoundError` safe. Missing +third-party dependencies, syntax/import defects in Python modules, mixed +exceptions, runtime crashes, and ordinary test failures remain blocking. + +## Testing evidence + +The focused suite includes the exact `fast_mlsirm._core` collection shape plus +adversarial cases for: + +- wrong and mixed missing modules; +- inconsistent collection counts; +- failed tests and setup/teardown errors; +- internal pytest errors, crashes, and truncated output; +- malformed TOML and unsafe paths; +- changed Rust, Cargo, packaging, dependency, workflow, and native-stub inputs; +- stale, pending, failed, status-only, wrong-workflow, and misleading checks; +- malformed SHAs, duplicate requirements, unsafe JSON, and missing files; +- flat and GraphQL-shaped workflow metadata; +- both CLI success and fail-closed paths. + +Local verification before publication reported 81 tests passing with 220/220 +production statements and 98/98 production branches covered. Permanent central +quality and security workflows remain authoritative after the branch is pushed. + +## Interpretation limits + +This gate establishes neither product correctness nor scientific validity. It +only prevents a known source-only sandbox limitation from being confused with a +Python defect while preserving exact-head native evidence. Parameter recovery, +CPU/GPU parity, psychometric validity, fairness, and release readiness remain +separate product obligations. + +## Rollback + +Rollback removes the helper, tests, and workflow integration. The prior behavior +is fail-closed: any missing native module causes central coverage failure. No +rollback requires weakening branch protection, deleting repository tests, or +introducing a Python substitute for Rust arithmetic. + +## References + +GitHub. (2026). *REST API endpoints for workflow runs*. GitHub Docs. +https://docs.github.com/en/rest/actions/workflow-runs + +Maturin contributors. (2026). *Bindings*. Maturin user guide. +https://www.maturin.rs/bindings + +Maturin contributors. (2026). *Configuration*. Maturin user guide. +https://www.maturin.rs/config + +Maturin contributors. (2026). *Introduction: Mixed Rust/Python projects*. +Maturin user guide. https://www.maturin.rs/ + +Python Software Foundation. (2026). *The import system*. Python documentation. +https://docs.python.org/3/reference/import.html + +PyO3 Project and Contributors. (2026). *Building and distribution*. PyO3 user +guide. https://pyo3.rs/main/building-and-distribution + +PyO3 Project and Contributors. (2026). *Python modules*. PyO3 user guide. +https://pyo3.rs/main/module diff --git a/docs/doctoring/python-native-extension-peer-file-safety.md b/docs/doctoring/python-native-extension-peer-file-safety.md new file mode 100644 index 000000000..965e67900 --- /dev/null +++ b/docs/doctoring/python-native-extension-peer-file-safety.md @@ -0,0 +1,59 @@ +# Descriptor-safe native peer evidence reads + +## Decision + +The native-extension peer-evidence gate treats pytest logs, project metadata, changed-file inventories, and check-run receipts as hostile data. Every accepted local evidence file is therefore read through one bounded regular-file routine that rejects symlinked lexical paths, final-component links, non-regular files, oversized metadata, descriptor/path identity changes, and growth during the read. + +```mermaid +flowchart LR + A[Untrusted evidence path] --> B[Lexical absolute path] + B --> C{Strict resolution equals lexical path?} + C -- no --> X[Fail closed] + C -- yes --> D[Open read-only with O_NOFOLLOW] + D --> E[fstat + no-follow path stat] + E --> F{Regular, same identity, bounded size?} + F -- no --> X + F -- yes --> G[Read at most limit + 1] + G --> H{Descriptor and live path unchanged?} + H -- no --> X + H -- yes --> I[Return inert bytes] +``` + +## Trust boundary + +The routine does not execute, import, extract, install, or otherwise interpret caller artifacts. It returns bytes only after the opened descriptor and the live lexical path agree. The subsequent UTF-8, TOML, pytest-log, and JSON parsers remain responsible for their own syntax and semantic validation. + +`O_NOFOLLOW` protects the final component on operating systems that expose it. Strict lexical-versus-resolved comparisons protect parent components and are repeated after the bounded read. Descriptor metadata is sampled before and after reading, and the live no-follow path identity is compared with the descriptor. Any `OSError`, unsupported path, race signal, size overflow, or metadata change produces no evidence rather than a partial result. + +## Verification + +The permanent Python 3.10/3.14 quality workflow executes realistic regressions for: + +- a regular file reached through a symlinked parent directory; +- a read that exceeds the declared byte limit after initial metadata validation; +- descriptor metadata replacement during the read; +- live path identity replacement; +- post-open lexical path retargeting; and +- an unchanged bounded regular file. + +The helper remains subject to 100% production statement and branch coverage, 100% public docstrings, Python compilation, and the complete pre-existing hostile-input suite. + +## Operational failure and rollback + +A new rejection is intentionally fail-closed. Operators should first determine whether the evidence producer emitted a symlink, replaced a file concurrently, exceeded the documented limit, or wrote after sealing. The producer must publish a fresh immutable evidence snapshot; the gate must not raise its limits or weaken identity checks to consume unstable input. + +Rollback means reverting the entire descriptor-safety commit and its regressions together. Removing only a regression, adding a broad exception, following links, or accepting a changed descriptor is prohibited because it would make a passing check weaker than the documented trust boundary. + +## Claims deliberately not made + +This control does not attest the semantic truth of a repository check, prove that a compiled extension is safe, or convert deferred source-only coverage into passing evidence. It only prevents mutable filesystem aliases and bounded-read races from becoming trusted input to the separately enforced exact-head peer-check policy. + +## References + +Institute of Electrical and Electronics Engineers, & The Open Group. (2024). *open — Open a file*. In *The Open Group Base Specifications, Issue 8 (IEEE Std 1003.1-2024)*. https://pubs.opengroup.org/onlinepubs/9799919799/functions/open.html + +MITRE. (n.d.). *CWE-59: Improper link resolution before file access ('link following')*. CWE. Retrieved August 7, 2026, from https://cwe.mitre.org/data/definitions/59.html + +MITRE. (n.d.). *CWE-367: Time-of-check time-of-use (TOCTOU) race condition*. CWE. Retrieved August 7, 2026, from https://cwe.mitre.org/data/definitions/367.html + +Python Software Foundation. (2026). *os — Miscellaneous operating system interfaces*. Python 3.14.6 documentation. https://docs.python.org/3.14/library/os.html diff --git a/scripts/ci/python_native_extension_peer_gate.py b/scripts/ci/python_native_extension_peer_gate.py new file mode 100644 index 000000000..ee186fd15 --- /dev/null +++ b/scripts/ci/python_native_extension_peer_gate.py @@ -0,0 +1,535 @@ +#!/usr/bin/env python3 +"""Classify missing PyO3 extensions and verify exact-head native peer checks.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path, PurePosixPath +import re +import stat +import sys +from typing import Any, Sequence + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - Python 3.10 compatibility lane. + import tomli as tomllib + + +MAX_LOG_BYTES = 2_000_000 +MAX_METADATA_BYTES = 262_144 +MAX_CHECK_BYTES = 1_000_000 +DOTTED_MODULE_RE = re.compile( + r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+\Z" +) +MISSING_MODULE_RE = re.compile( + r"ModuleNotFoundError:\s+No module named ['\"]([^'\"]+)['\"]" +) +COLLECTION_ERROR_RE = re.compile( + r"^_+\s+ERROR collecting\s+.+?\s+_+\s*$", re.MULTILINE +) +INTERRUPTED_RE = re.compile( + r"Interrupted:\s+(\d+)\s+errors?\s+during\s+collection", re.IGNORECASE +) +EXCEPTION_LINE_RE = re.compile( + r"^E\s+([A-Za-z_][A-Za-z0-9_.]*(?:Error|Exception))(?::|\s*$)", + re.MULTILINE, +) +FORBIDDEN_LOG_MARKERS = ( + " output truncated:", + "INTERNALERROR>", + "Fatal Python error", + "Segmentation fault", + "ERROR at setup", + "ERROR at teardown", + "=== FAILURES ===", +) +LOCK_FILE_NAMES = { + "Cargo.lock", + "Pipfile.lock", + "poetry.lock", + "pylock.toml", + "uv.lock", +} +PACKAGING_FILE_NAMES = { + "MANIFEST.in", + "build.rs", + "setup.cfg", + "setup.py", +} + + +def _read_bounded_regular(path: Path, maximum: int) -> bytes | None: + """Return bounded regular-file bytes, or ``None`` for unsafe input.""" + + directory_fd: int | None = None + file_fd: int | None = None + try: + if maximum < 0: + return None + absolute_path = Path(os.path.abspath(os.fspath(path))) + path_parts = absolute_path.parts + if len(path_parts) < 2 or any(part in {"", ".", ".."} for part in path_parts[1:]): + return None + if absolute_path.resolve(strict=True) != absolute_path: + return None + + directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC + no_follow = getattr(os, "O_NOFOLLOW", 0) + directory_fd = os.open(path_parts[0], directory_flags) + for component in path_parts[1:-1]: + next_fd = os.open( + component, + directory_flags | no_follow, + dir_fd=directory_fd, + ) + os.close(directory_fd) + directory_fd = next_fd + + file_fd = os.open( + path_parts[-1], + os.O_RDONLY | os.O_CLOEXEC | no_follow, + dir_fd=directory_fd, + ) + metadata_before = os.fstat(file_fd) + if not stat.S_ISREG(metadata_before.st_mode) or metadata_before.st_size > maximum: + return None + + chunks: list[bytes] = [] + remaining = maximum + 1 + while remaining > 0: + chunk = os.read(file_fd, remaining) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + payload = b"".join(chunks) + metadata_after = os.fstat(file_fd) + live_metadata = os.stat(absolute_path, follow_symlinks=False) + descriptor_identity_before = ( + metadata_before.st_dev, + metadata_before.st_ino, + stat.S_IFMT(metadata_before.st_mode), + ) + descriptor_identity_after = ( + metadata_after.st_dev, + metadata_after.st_ino, + stat.S_IFMT(metadata_after.st_mode), + ) + live_identity = ( + live_metadata.st_dev, + live_metadata.st_ino, + stat.S_IFMT(live_metadata.st_mode), + ) + if ( + len(payload) > maximum + or descriptor_identity_before != descriptor_identity_after + or descriptor_identity_after != live_identity + or metadata_before.st_size != metadata_after.st_size + or metadata_before.st_mtime_ns != metadata_after.st_mtime_ns + or absolute_path.resolve(strict=True) != absolute_path + ): + return None + return payload + except OSError: + return None + finally: + if file_fd is not None: + os.close(file_fd) + if directory_fd is not None: + os.close(directory_fd) + + +def _read_text(path: Path, maximum: int) -> str | None: + """Return bounded UTF-8 text, rejecting malformed or unsafe input.""" + + payload = _read_bounded_regular(path, maximum) + if payload is None: + return None + try: + return payload.decode("utf-8") + except UnicodeDecodeError: + return None + + +def _safe_relative_path(raw_path: str) -> PurePosixPath | None: + """Return a normalized repository-relative POSIX path when safe.""" + + if not raw_path or "\x00" in raw_path or "\\" in raw_path: + return None + segments = raw_path.split("/") + if any(segment in {"", ".", ".."} for segment in segments): + return None + return PurePosixPath(raw_path) + + +def _maturin_contract( + pyproject: Path, +) -> tuple[str, PurePosixPath, PurePosixPath] | None: + """Return the native module, Cargo manifest, and Python source directory.""" + + payload = _read_bounded_regular(pyproject, MAX_METADATA_BYTES) + if payload is None: + return None + try: + metadata = tomllib.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, tomllib.TOMLDecodeError): + return None + build_system = metadata.get("build-system") + tool = metadata.get("tool") + if not isinstance(build_system, dict) or not isinstance(tool, dict): + return None + maturin = tool.get("maturin") + if not isinstance(maturin, dict): + return None + if build_system.get("build-backend") != "maturin": + return None + if maturin.get("bindings") != "pyo3": + return None + + module_name = maturin.get("module-name") + manifest_value = maturin.get("manifest-path", "Cargo.toml") + python_source_value = maturin.get("python-source", ".") + if ( + not isinstance(module_name, str) + or DOTTED_MODULE_RE.fullmatch(module_name) is None + or not isinstance(manifest_value, str) + or not isinstance(python_source_value, str) + ): + return None + manifest_path = _safe_relative_path(manifest_value) + python_source = ( + PurePosixPath(".") + if python_source_value == "." + else _safe_relative_path(python_source_value) + ) + if ( + manifest_path is None + or manifest_path.name != "Cargo.toml" + or python_source is None + ): + return None + return module_name, manifest_path, python_source + + +def _read_changed_files(path: Path) -> tuple[PurePosixPath, ...] | None: + """Return validated changed paths from a bounded newline-delimited file.""" + + text = _read_text(path, MAX_METADATA_BYTES) + if text is None: + return None + paths: list[PurePosixPath] = [] + seen: set[str] = set() + for raw_line in text.splitlines(): + raw_path = raw_line.strip() + if not raw_path: + continue + parsed = _safe_relative_path(raw_path) + if parsed is None or parsed.as_posix() in seen: + return None + seen.add(parsed.as_posix()) + paths.append(parsed) + return tuple(paths) + + +def _repository_contract_paths( + *, + repo_root_path: Path | None, + pyproject_path: Path, + manifest_path: PurePosixPath, + python_source: PurePosixPath, +) -> tuple[PurePosixPath, PurePosixPath, PurePosixPath] | None: + """Return repository-relative PyO3 contract paths for one project.""" + + candidate_root = pyproject_path.parent if repo_root_path is None else repo_root_path + try: + if not candidate_root.is_dir() or candidate_root.is_symlink(): + return None + repository_root = candidate_root.resolve() + project_root = pyproject_path.parent.resolve() + resolved_pyproject = pyproject_path.resolve() + if resolved_pyproject.parent != project_root: + return None + project_prefix_path = project_root.relative_to(repository_root) + except (OSError, ValueError): + return None + + project_prefix = ( + PurePosixPath(".") + if not project_prefix_path.parts + else PurePosixPath(project_prefix_path.as_posix()) + ) + relative_pyproject = project_prefix / pyproject_path.name + if relative_pyproject.name != "pyproject.toml": + return None + relative_manifest = project_prefix / manifest_path + relative_python_source = ( + project_prefix + if python_source == PurePosixPath(".") + else project_prefix / python_source + ) + return relative_pyproject, relative_manifest, relative_python_source + + +def _touches_native_or_trust_boundary( + changed_paths: tuple[PurePosixPath, ...], + *, + pyproject_path: PurePosixPath, + manifest_path: PurePosixPath, + module_name: str, + python_source: PurePosixPath, +) -> bool: + """Return whether changed files invalidate unchanged-extension deferral.""" + + manifest_parent = manifest_path.parent + module_stub = ( + python_source / PurePosixPath(*module_name.split(".")) + ).with_suffix(".pyi") + for path in changed_paths: + path_text = path.as_posix() + if path == pyproject_path or path == manifest_path: + return True + if path.name in LOCK_FILE_NAMES or path.name in PACKAGING_FILE_NAMES: + return True + if path.name == "Cargo.toml" or path.suffix == ".rs": + return True + if path == module_stub: + return True + if path.parts[:2] in {(".github", "workflows"), (".github", "actions")}: + return True + if path.name.startswith("requirements") and path.suffix == ".txt": + return True + if "requirements" in path.parts: + return True + if manifest_parent != PurePosixPath(".") and path.is_relative_to(manifest_parent): + return True + if path_text.endswith("/pyproject.toml"): + return True + return False + + +def classify_pytest_failure( + log_text: str, + *, + module_name: str, +) -> bool: + """Return whether pytest failed only because one declared module was absent.""" + + if not log_text or any(marker in log_text for marker in FORBIDDEN_LOG_MARKERS): + return False + if re.search(r"^FAILED\s+", log_text, re.MULTILINE): + return False + + missing_modules = MISSING_MODULE_RE.findall(log_text) + collection_errors = COLLECTION_ERROR_RE.findall(log_text) + interruptions = INTERRUPTED_RE.findall(log_text) + if ( + not missing_modules + or not collection_errors + or len(interruptions) != 1 + or any(name != module_name for name in missing_modules) + ): + return False + if len(missing_modules) != len(collection_errors): + return False + if int(interruptions[0]) != len(collection_errors): + return False + + escaped_module = re.escape(module_name) + imported_module_count = len( + re.findall( + rf"^\s*(?:from\s+{escaped_module}\s+import|import\s+{escaped_module}(?:\s|$))", + log_text, + re.MULTILINE, + ) + ) + if imported_module_count < len(collection_errors): + return False + + exception_types = EXCEPTION_LINE_RE.findall(log_text) + return bool(exception_types) and all( + exception_type == "ModuleNotFoundError" + for exception_type in exception_types + ) + + +def classify_pytest_inputs( + *, + log_path: Path, + pyproject_path: Path, + changed_files_path: Path, + repo_root_path: Path | None = None, + logical_pyproject_path: Path | None = None, +) -> str | None: + """Return the safely deferred module name, or ``None`` when blocking.""" + + contract = _maturin_contract(pyproject_path) + log_text = _read_text(log_path, MAX_LOG_BYTES) + changed_paths = _read_changed_files(changed_files_path) + if contract is None or log_text is None or changed_paths is None: + return None + + module_name, manifest_path, python_source = contract + repository_paths = _repository_contract_paths( + repo_root_path=repo_root_path, + pyproject_path=logical_pyproject_path or pyproject_path, + manifest_path=manifest_path, + python_source=python_source, + ) + if repository_paths is None: + return None + relative_pyproject, relative_manifest, relative_python_source = repository_paths + if _touches_native_or_trust_boundary( + changed_paths, + pyproject_path=relative_pyproject, + manifest_path=relative_manifest, + module_name=module_name, + python_source=relative_python_source, + ): + return None + if not classify_pytest_failure(log_text, module_name=module_name): + return None + return module_name + + +def _workflow_name(check: dict[str, Any]) -> str | None: + """Return a normalized workflow name from one check-run record.""" + + workflow = check.get("workflow") + if isinstance(workflow, str): + return workflow + suite = check.get("checkSuite") + if not isinstance(suite, dict): + return None + workflow_run = suite.get("workflowRun") + if not isinstance(workflow_run, dict): + return None + nested = workflow_run.get("workflow") + if not isinstance(nested, dict): + return None + name = nested.get("name") + return name if isinstance(name, str) else None + + +def _read_checks(path: Path) -> list[dict[str, Any]] | None: + """Return a bounded list of normalized check-run records.""" + + text = _read_text(path, MAX_CHECK_BYTES) + if text is None: + return None + try: + payload = json.loads(text) + except json.JSONDecodeError: + return None + if not isinstance(payload, list) or any(not isinstance(item, dict) for item in payload): + return None + return payload + + +def has_required_exact_head_checks( + checks: list[dict[str, Any]], + *, + head_sha: str, + required_checks: tuple[tuple[str, str], ...], +) -> bool: + """Return whether every trusted exact-head check completed successfully.""" + + if re.fullmatch(r"[0-9a-fA-F]{40}", head_sha) is None or not required_checks: + return False + if len(set(required_checks)) != len(required_checks): + return False + + for workflow, name in required_checks: + matches = [ + check + for check in checks + if check.get("__typename") == "CheckRun" + and _workflow_name(check) == workflow + and check.get("name") == name + and check.get("head_sha") == head_sha + ] + if not matches: + return False + if any( + str(check.get("status") or "").upper() != "COMPLETED" + or str(check.get("conclusion") or "").upper() != "SUCCESS" + for check in matches + ): + return False + return True + + +def _parse_required_check(value: str) -> tuple[str, str]: + """Parse one trusted ``WORKFLOW::CHECK`` requirement.""" + + workflow, separator, name = value.partition("::") + if not separator or not workflow.strip() or not name.strip(): + raise argparse.ArgumentTypeError( + "required checks must use non-empty WORKFLOW::CHECK syntax" + ) + return workflow.strip(), name.strip() + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + """Parse the native-extension peer-gate command line.""" + + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + + classify = subparsers.add_parser("classify-pytest") + classify.add_argument("--log", type=Path, required=True) + classify.add_argument("--pyproject", type=Path, required=True) + classify.add_argument("--changed-files", type=Path, required=True) + classify.add_argument("--repo-root", type=Path) + classify.add_argument("--logical-pyproject", type=Path) + + require = subparsers.add_parser("require-checks") + require.add_argument("--checks-json", type=Path, required=True) + require.add_argument("--head-sha", required=True) + require.add_argument( + "--required-check", + action="append", + type=_parse_required_check, + required=True, + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the selected fail-closed native-extension peer gate.""" + + args = parse_args(argv) + if args.command == "classify-pytest": + module_name = classify_pytest_inputs( + log_path=args.log, + pyproject_path=args.pyproject, + changed_files_path=args.changed_files, + repo_root_path=args.repo_root, + logical_pyproject_path=args.logical_pyproject, + ) + if module_name is None: + print("pytest failure is not safely deferrable", file=sys.stderr) + return 1 + print( + "pytest collection failed exclusively because unchanged declared " + f"native module {module_name} was absent" + ) + return 0 + + checks = _read_checks(args.checks_json) + required_checks = tuple(args.required_check) + if checks is not None and has_required_exact_head_checks( + checks, + head_sha=args.head_sha, + required_checks=required_checks, + ): + print("all required exact-head native peer checks succeeded") + return 0 + print("required exact-head native peer checks were not proven", file=sys.stderr) + return 1 + + +if __name__ == "__main__": # pragma: no cover - exercised by workflow entrypoint. + raise SystemExit(main()) diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index daeaa37a2..2dbb0e03c 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -701,9 +701,9 @@ def test_opencode_target_coverage_materializes_only_after_authorized_dispatch(): assert "GIT_CONFIG_NOSYSTEM=1" in measure_step assert "GIT_CONFIG_GLOBAL=/dev/null" in measure_step assert "-c safe.directory=/work" in measure_step - assert measure_step.count("GIT_CONFIG_COUNT=1") == 3 - assert measure_step.count("GIT_CONFIG_KEY_0=safe.directory") == 3 - assert measure_step.count("GIT_CONFIG_VALUE_0=/work") == 3 + assert measure_step.count("GIT_CONFIG_COUNT=1") == 4 + assert measure_step.count("GIT_CONFIG_KEY_0=safe.directory") == 4 + assert measure_step.count("GIT_CONFIG_VALUE_0=/work") == 4 assert "-c core.fsmonitor=false" in measure_step assert "-c core.hooksPath=/dev/null" in measure_step assert "git -c core.quotePath=false ls-files" not in measure_step @@ -2121,11 +2121,11 @@ def test_opencode_privileged_review_security_boundaries_are_fail_closed(): assert syntax_step < measure_step assert "\n - name:" not in measure.split("\n run: |", 1)[1] assert 'UV_NO_BUILD: "1"' in measure - assert measure.count("GITHUB_ENV=/dev/null") == 3 - assert measure.count("GITHUB_PATH=/dev/null") == 3 - assert measure.count("GITHUB_OUTPUT=/dev/null") == 3 - assert measure.count("GITHUB_STEP_SUMMARY=/dev/null") == 3 - assert measure.count("BASH_ENV=/dev/null") == 3 + assert measure.count("GITHUB_ENV=/dev/null") == 4 + assert measure.count("GITHUB_PATH=/dev/null") == 4 + assert measure.count("GITHUB_OUTPUT=/dev/null") == 4 + assert measure.count("GITHUB_STEP_SUMMARY=/dev/null") == 4 + assert measure.count("BASH_ENV=/dev/null") == 4 assert "uv sync --project" not in measure assert "uv run --no-project" not in measure assert "uv run --no-build" not in measure diff --git a/tests/test_python_native_extension_peer_gate.py b/tests/test_python_native_extension_peer_gate.py new file mode 100644 index 000000000..ff18d6a25 --- /dev/null +++ b/tests/test_python_native_extension_peer_gate.py @@ -0,0 +1,610 @@ +"""Tests for the bounded PyO3 native-extension peer gate.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path, PurePosixPath + +import pytest + +from scripts.ci import python_native_extension_peer_gate as gate + + +PYPROJECT = """\ +[build-system] +requires = ["maturin>=1.10,<2.0"] +build-backend = "maturin" + +[tool.maturin] +bindings = "pyo3" +manifest-path = "crates/fast-mlsirm-py/Cargo.toml" +module-name = "fast_mlsirm._core" +python-source = "python" +""" + +LOG = """\ +============================= test session starts ============================== +collected 0 items / 2 errors + +_____________ ERROR collecting tests/test_cov_f_fit.py ______________ +ImportError while importing test module '/work/tests/test_cov_f_fit.py'. +Traceback: +/usr/lib/python3/importlib/__init__.py:126: in import_module + return _bootstrap._gcd_import(name[level:], package, level) +tests/test_cov_f_fit.py:4: in + from fast_mlsirm._core import neg_loglik_and_grad +E ModuleNotFoundError: No module named 'fast_mlsirm._core' +_____________ ERROR collecting tests/test_mle.py ______________ +ImportError while importing test module '/work/tests/test_mle.py'. +Traceback: +tests/test_mle.py:3: in + import fast_mlsirm._core +E ModuleNotFoundError: No module named "fast_mlsirm._core" +!!!!!!!!!!!!!!!!!!! Interrupted: 2 errors during collection !!!!!!!!!!!!!!!!!!!! +============================== 2 errors in 0.42s =============================== +""" + + +def write(path: Path, text: str) -> Path: + """Write UTF-8 fixture text and return its path.""" + + path.write_text(text, encoding="utf-8") + return path + + +def valid_inputs(tmp_path: Path) -> tuple[Path, Path, Path]: + """Create one valid log, pyproject, and changed-file fixture.""" + + return ( + write(tmp_path / "pytest.log", LOG), + write(tmp_path / "pyproject.toml", PYPROJECT), + write( + tmp_path / "changed.txt", + "python/fast_mlsirm/scoring/reporting.py\n" + "tests/test_scoring_reporting.py\n", + ), + ) + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("src/lib.rs", "src/lib.rs"), + ("", None), + ("../src/lib.rs", None), + ("/src/lib.rs", None), + ("src\\lib.rs", None), + ("src/\x00lib.rs", None), + ("./src/lib.rs", None), + ], +) +def test_safe_relative_path(raw: str, expected: str | None) -> None: + """Repository paths reject traversal, aliases, separators, and NUL.""" + + result = gate._safe_relative_path(raw) + assert (result.as_posix() if result is not None else None) == expected + + +def test_bounded_reader_rejects_missing_directory_symlink_large_and_oserror( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Only bounded regular files are accepted.""" + + missing = tmp_path / "missing" + directory = tmp_path / "directory" + directory.mkdir() + target = write(tmp_path / "target", "ok") + symlink = tmp_path / "link" + symlink.symlink_to(target) + large = write(tmp_path / "large", "abcd") + + assert gate._read_bounded_regular(missing, 10) is None + assert gate._read_bounded_regular(directory, 10) is None + assert gate._read_bounded_regular(symlink, 10) is None + assert gate._read_bounded_regular(large, 3) is None + assert gate._read_bounded_regular(target, 10) == b"ok" + + monkeypatch.setattr( + gate.os, + "read", + lambda _file_descriptor, _count: (_ for _ in ()).throw(OSError()), + ) + assert gate._read_bounded_regular(target, 10) is None + + +def test_read_text_rejects_non_utf8(tmp_path: Path) -> None: + """Malformed UTF-8 cannot influence classification.""" + + path = tmp_path / "bad" + path.write_bytes(b"\xff") + assert gate._read_text(path, 10) is None + + +@pytest.mark.parametrize( + "replacement", + [ + 'build-backend = "setuptools.build_meta"', + 'bindings = "cffi"', + 'module-name = "not_dotted"', + 'manifest-path = "../Cargo.toml"', + 'manifest-path = "Cargo.lock"', + ], +) +def test_maturin_contract_rejects_invalid_contracts( + tmp_path: Path, replacement: str +) -> None: + """The classifier requires explicit safe maturin/PyO3 metadata.""" + + content = PYPROJECT + if replacement.startswith("build-backend"): + content = content.replace('build-backend = "maturin"', replacement) + elif replacement.startswith("bindings"): + content = content.replace('bindings = "pyo3"', replacement) + elif replacement.startswith("module-name"): + content = content.replace('module-name = "fast_mlsirm._core"', replacement) + else: + content = content.replace( + 'manifest-path = "crates/fast-mlsirm-py/Cargo.toml"', replacement + ) + assert gate._maturin_contract(write(tmp_path / "pyproject.toml", content)) is None + + +@pytest.mark.parametrize( + "content", + [ + "", + "not = [valid", + "[build-system]\nbuild-backend = \"maturin\"\n", + "[tool]\nvalue = 1\n", + "[tool.maturin]\nbindings = \"pyo3\"\nmodule-name = \"a.b\"\n", + "[build-system]\nbuild-backend = \"maturin\"\n[tool]\nmaturin = 1\n", + ( + "[build-system]\nbuild-backend = \"maturin\"\n" + "[tool.maturin]\nbindings = \"pyo3\"\nmodule-name = 3\n" + ), + ], +) +def test_maturin_contract_rejects_malformed_metadata( + tmp_path: Path, content: str +) -> None: + """Missing and malformed TOML structures fail closed.""" + + assert gate._maturin_contract(write(tmp_path / "pyproject.toml", content)) is None + + +def test_maturin_contract_uses_default_manifest(tmp_path: Path) -> None: + """A safe root Cargo manifest is the maturin default.""" + + content = PYPROJECT.replace( + 'manifest-path = "crates/fast-mlsirm-py/Cargo.toml"\n', "" + ) + assert gate._maturin_contract(write(tmp_path / "pyproject.toml", content)) == ( + "fast_mlsirm._core", + PurePosixPath("Cargo.toml"), + PurePosixPath("python"), + ) + + +@pytest.mark.parametrize( + "changed", + [ + "pyproject.toml\n", + "Cargo.toml\n", + "Cargo.lock\n", + "src/lib.rs\n", + "build.rs\n", + "setup.py\n", + "requirements-ci.txt\n", + ".github/workflows/ci.yml\n", + ".github/actions/setup/action.yml\n", + "python/fast_mlsirm/_core.pyi\n", + "crates/fast-mlsirm-py/README.md\n", + "nested/pyproject.toml\n", + "uv.lock\n", + ], +) +def test_native_trust_boundary_changes_block_deferral( + tmp_path: Path, changed: str +) -> None: + """Native, packaging, dependency, and CI changes require direct builds.""" + + log, pyproject, changed_path = valid_inputs(tmp_path) + changed_path.write_text(changed, encoding="utf-8") + assert gate.classify_pytest_inputs( + log_path=log, + pyproject_path=pyproject, + changed_files_path=changed_path, + ) is None + + +@pytest.mark.parametrize( + "changed", + [ + "../bad.py\n", + "same.py\nsame.py\n", + "C:\\bad.py\n", + ], +) +def test_changed_file_list_rejects_unsafe_entries( + tmp_path: Path, changed: str +) -> None: + """Untrusted path lists reject traversal, duplicates, and platform aliases.""" + + path = write(tmp_path / "changed.txt", changed) + assert gate._read_changed_files(path) is None + + +def test_changed_file_list_ignores_blank_lines(tmp_path: Path) -> None: + """Blank lines do not create path aliases.""" + + path = write(tmp_path / "changed.txt", "\npython/pkg.py\n\n") + assert gate._read_changed_files(path) == (Path("python/pkg.py"),) + + +@pytest.mark.parametrize( + "log", + [ + "", + LOG.replace("fast_mlsirm._core", "other_module", 1), + LOG.replace("Interrupted: 2 errors", "Interrupted: 1 error"), + LOG.replace("Interrupted: 2 errors", "Interrupted: 2 errors") + "\nFAILED x.py::test_x\n", + LOG + "\n=== FAILURES ===\n", + LOG + "\nINTERNALERROR> boom\n", + LOG + "\nFatal Python error\n", + LOG + "\nSegmentation fault\n", + LOG + "\nERROR at setup\n", + LOG + "\nERROR at teardown\n", + LOG.replace( + "E ModuleNotFoundError: No module named 'fast_mlsirm._core'", + "E ImportError: bad import", + 1, + ), + LOG.replace( + "_____________ ERROR collecting tests/test_mle.py ______________\n", "" + ), + LOG.replace( + 'E ModuleNotFoundError: No module named "fast_mlsirm._core"\n', "" + ), + LOG.replace("Interrupted: 2 errors during collection", "no interruption"), + LOG + "\n output truncated: 999 lines\n", + ], +) +def test_pytest_classifier_rejects_ambiguous_or_mixed_failures(log: str) -> None: + """Only complete, exclusive declared-module collection failures defer.""" + + assert not gate.classify_pytest_failure( + log, + module_name="fast_mlsirm._core", + ) + + +def test_pytest_classifier_accepts_exact_missing_extension() -> None: + """A complete exact-module collection failure is classifiable.""" + + assert gate.classify_pytest_failure( + LOG, + module_name="fast_mlsirm._core", + ) + + +def test_classify_inputs_accepts_python_only_change(tmp_path: Path) -> None: + """Python-only changes may defer to trusted native peer evidence.""" + + log, pyproject, changed = valid_inputs(tmp_path) + assert gate.classify_pytest_inputs( + log_path=log, + pyproject_path=pyproject, + changed_files_path=changed, + ) == "fast_mlsirm._core" + + +def test_classify_inputs_uses_sealed_metadata_with_logical_project_path( + tmp_path: Path, +) -> None: + """Untrusted runtime mutation cannot replace the pre-test project contract.""" + + log, pyproject, changed = valid_inputs(tmp_path) + snapshot = write(tmp_path / "sealed-pyproject.snapshot", PYPROJECT) + pyproject.write_text("[project]\nname = 'mutated'\n", encoding="utf-8") + + assert gate.classify_pytest_inputs( + log_path=log, + pyproject_path=snapshot, + logical_pyproject_path=pyproject, + changed_files_path=changed, + repo_root_path=tmp_path, + ) == "fast_mlsirm._core" + + +def test_classify_inputs_rejects_unsafe_input_files(tmp_path: Path) -> None: + """Missing or malformed inputs block classification.""" + + log, pyproject, changed = valid_inputs(tmp_path) + log.unlink() + assert gate.classify_pytest_inputs( + log_path=log, + pyproject_path=pyproject, + changed_files_path=changed, + ) is None + + +def test_workflow_name_supports_flat_and_nested_records() -> None: + """Check records normalize trusted flat and GraphQL workflow names.""" + + assert gate._workflow_name({"workflow": "CI"}) == "CI" + assert gate._workflow_name({}) is None + assert gate._workflow_name({"checkSuite": 1}) is None + assert gate._workflow_name({"checkSuite": {"workflowRun": 1}}) is None + assert gate._workflow_name( + {"checkSuite": {"workflowRun": {"workflow": 1}}} + ) is None + assert gate._workflow_name( + {"checkSuite": {"workflowRun": {"workflow": {"name": 1}}}} + ) is None + assert gate._workflow_name( + {"checkSuite": {"workflowRun": {"workflow": {"name": "CI"}}}} + ) == "CI" + + +def successful_checks(head: str) -> list[dict[str, object]]: + """Return exact-head Python, Rust, and package check runs.""" + + return [ + { + "__typename": "CheckRun", + "workflow": "CI", + "name": name, + "head_sha": head, + "status": "COMPLETED", + "conclusion": "SUCCESS", + } + for name in ("python", "rust", "package") + ] + + +@pytest.mark.parametrize( + "mutation", + [ + lambda checks: checks.pop(), + lambda checks: checks[0].update(head_sha="b" * 40), + lambda checks: checks[0].update(status="IN_PROGRESS"), + lambda checks: checks[0].update(conclusion="FAILURE"), + lambda checks: checks[0].update(__typename="StatusContext"), + lambda checks: checks[0].update(workflow="Other"), + lambda checks: checks[0].update(name="Python"), + ], +) +def test_exact_head_checks_reject_missing_stale_pending_or_spoofed( + mutation, +) -> None: + """Every required exact-head CheckRun must complete successfully.""" + + head = "a" * 40 + checks = successful_checks(head) + mutation(checks) + assert not gate.has_required_exact_head_checks( + checks, + head_sha=head, + required_checks=( + ("CI", "python"), + ("CI", "rust"), + ("CI", "package"), + ), + ) + + +def test_exact_head_checks_accept_nested_workflow_records() -> None: + """GraphQL-shaped workflow names remain acceptable after normalization.""" + + head = "a" * 40 + checks = successful_checks(head) + checks[0].pop("workflow") + checks[0]["checkSuite"] = { + "workflowRun": {"workflow": {"name": "CI"}} + } + assert gate.has_required_exact_head_checks( + checks, + head_sha=head, + required_checks=( + ("CI", "python"), + ("CI", "rust"), + ("CI", "package"), + ), + ) + + +@pytest.mark.parametrize( + ("head", "required"), + [ + ("bad", (("CI", "python"),)), + ("a" * 40, ()), + ("a" * 40, (("CI", "python"), ("CI", "python"))), + ], +) +def test_exact_head_checks_reject_invalid_contract( + head: str, required: tuple[tuple[str, str], ...] +) -> None: + """Malformed SHAs and duplicate or empty requirements fail closed.""" + + assert not gate.has_required_exact_head_checks( + successful_checks("a" * 40), + head_sha=head, + required_checks=required, + ) + + +def test_read_checks_rejects_unsafe_or_invalid_json(tmp_path: Path) -> None: + """Peer evidence must be a bounded JSON list of objects.""" + + assert gate._read_checks(write(tmp_path / "bad.json", "{")) is None + assert gate._read_checks(write(tmp_path / "scalar.json", "{}")) is None + assert gate._read_checks(write(tmp_path / "mixed.json", "[1]")) is None + valid = write(tmp_path / "valid.json", '[{"name":"python"}]') + assert gate._read_checks(valid) == [{"name": "python"}] + + +@pytest.mark.parametrize( + "value", + ["CI::python", " CI :: python "], +) +def test_parse_required_check(value: str) -> None: + """Trusted check specifications use exact workflow and job names.""" + + assert gate._parse_required_check(value) == ("CI", "python") + + +@pytest.mark.parametrize("value", ["CI", "::python", "CI::"]) +def test_parse_required_check_rejects_malformed(value: str) -> None: + """Empty or delimiter-free check specifications are rejected.""" + + with pytest.raises(argparse.ArgumentTypeError): + gate._parse_required_check(value) + + +def test_maturin_contract_rejects_unsafe_file(tmp_path: Path) -> None: + """Missing project metadata cannot define a native peer contract.""" + + assert gate._maturin_contract(tmp_path / "missing.toml") is None + + +def test_changed_file_reader_rejects_missing_file(tmp_path: Path) -> None: + """Missing changed-file evidence blocks deferral.""" + + assert gate._read_changed_files(tmp_path / "missing.txt") is None + + +def test_classify_inputs_rejects_resolve_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Filesystem resolution failures do not produce a deferral.""" + + log, pyproject, changed = valid_inputs(tmp_path) + original = Path.resolve + + def fail_pyproject(path: Path, *args, **kwargs): + """Raise only while the classifier resolves the project file.""" + + if path == pyproject: + raise OSError("unavailable") + return original(path, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", fail_pyproject) + assert gate.classify_pytest_inputs( + log_path=log, + pyproject_path=pyproject, + changed_files_path=changed, + ) is None + + +def test_classify_inputs_rejects_resolved_pyproject_parent_mismatch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A replaced metadata path cannot escape its already-resolved project.""" + + log, pyproject, changed = valid_inputs(tmp_path) + original = Path.resolve + pyproject_resolutions = 0 + + def replace_pyproject(path: Path, *args, **kwargs): + """Resolve only the metadata file into a different parent.""" + + nonlocal pyproject_resolutions + if path == pyproject: + pyproject_resolutions += 1 + if pyproject_resolutions == 3: + return tmp_path / "replaced" / "pyproject.toml" + return original(path, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", replace_pyproject) + assert gate.classify_pytest_inputs( + log_path=log, + pyproject_path=pyproject, + changed_files_path=changed, + ) is None + + +def test_classify_inputs_rejects_nonmatching_log(tmp_path: Path) -> None: + """Valid metadata cannot defer an unrelated pytest failure.""" + + log, pyproject, changed = valid_inputs(tmp_path) + log.write_text("FAILED tests/test_x.py::test_x\n", encoding="utf-8") + assert gate.classify_pytest_inputs( + log_path=log, + pyproject_path=pyproject, + changed_files_path=changed, + ) is None + + +def test_read_checks_rejects_missing_file(tmp_path: Path) -> None: + """Missing peer-check evidence blocks approval.""" + + assert gate._read_checks(tmp_path / "missing.json") is None + + +def test_cli_classify_and_require_checks(tmp_path: Path, capsys) -> None: + """Both CLI operations emit explicit success and fail-closed diagnostics.""" + + log, pyproject, changed = valid_inputs(tmp_path) + assert gate.main( + [ + "classify-pytest", + "--log", + str(log), + "--pyproject", + str(pyproject), + "--changed-files", + str(changed), + ] + ) == 0 + assert "unchanged declared native module" in capsys.readouterr().out + + changed.write_text("Cargo.toml\n", encoding="utf-8") + assert gate.main( + [ + "classify-pytest", + "--log", + str(log), + "--pyproject", + str(pyproject), + "--changed-files", + str(changed), + ] + ) == 1 + assert "not safely deferrable" in capsys.readouterr().err + + head = "a" * 40 + checks_path = write( + tmp_path / "checks.json", + json.dumps(successful_checks(head)), + ) + assert gate.main( + [ + "require-checks", + "--checks-json", + str(checks_path), + "--head-sha", + head, + "--required-check", + "CI::python", + "--required-check", + "CI::rust", + "--required-check", + "CI::package", + ] + ) == 0 + assert "all required exact-head" in capsys.readouterr().out + + checks_path.write_text("[]", encoding="utf-8") + assert gate.main( + [ + "require-checks", + "--checks-json", + str(checks_path), + "--head-sha", + head, + "--required-check", + "CI::python", + ] + ) == 1 + assert "not proven" in capsys.readouterr().err diff --git a/tests/test_python_native_extension_peer_gate_file_safety.py b/tests/test_python_native_extension_peer_gate_file_safety.py new file mode 100644 index 000000000..c746b65e4 --- /dev/null +++ b/tests/test_python_native_extension_peer_gate_file_safety.py @@ -0,0 +1,142 @@ +"""Filesystem-race regressions for the native-extension peer-evidence gate.""" + +from __future__ import annotations + +import os +from pathlib import Path +from types import SimpleNamespace + +from scripts.ci import python_native_extension_peer_gate as gate + + +def test_bounded_reader_rejects_a_symlinked_ancestor(tmp_path: Path) -> None: + """Never trust a regular file reached through a symlinked parent directory.""" + + real_root = tmp_path / "real-root" + real_root.mkdir() + payload = real_root / "payload.txt" + payload.write_bytes(b"trusted-looking") + alias_root = tmp_path / "alias-root" + alias_root.symlink_to(real_root, target_is_directory=True) + + assert gate._read_bounded_regular(alias_root / payload.name, 64) is None + + +def test_bounded_reader_rejects_a_read_larger_than_the_declared_limit( + tmp_path: Path, + monkeypatch, +) -> None: + """Fail closed when a file grows between metadata validation and reading.""" + + payload = tmp_path / "payload.txt" + payload.write_bytes(b"x") + maximum = 16 + original_read = os.read + injected = False + + def oversized_read(file_descriptor: int, count: int) -> bytes: + nonlocal injected + if not injected: + injected = True + return b"z" * (maximum + 1) + return original_read(file_descriptor, count) + + monkeypatch.setattr(os, "read", oversized_read) + assert gate._read_bounded_regular(payload, maximum) is None + + +def test_bounded_reader_accepts_one_stable_regular_file(tmp_path: Path) -> None: + """Keep the ordinary bounded regular-file path available after hardening.""" + + payload = tmp_path / "payload.txt" + payload.write_bytes(b"stable") + + assert gate._read_bounded_regular(payload, 16) == b"stable" + + +def test_bounded_reader_rejects_invalid_bound_and_root_path(tmp_path: Path) -> None: + """Nonsensical bounds and paths cannot produce trusted bytes.""" + + payload = tmp_path / "payload.txt" + payload.write_bytes(b"stable") + + assert gate._read_bounded_regular(payload, -1) is None + assert gate._read_bounded_regular(Path(payload.anchor), 16) is None + + +def _changed_metadata(metadata: os.stat_result, **changes: int) -> SimpleNamespace: + """Return stat-like metadata with selected integer fields replaced.""" + + values = { + "st_dev": metadata.st_dev, + "st_ino": metadata.st_ino, + "st_mode": metadata.st_mode, + "st_size": metadata.st_size, + "st_mtime_ns": metadata.st_mtime_ns, + } + values.update(changes) + return SimpleNamespace(**values) + + +def test_bounded_reader_rejects_descriptor_metadata_change( + tmp_path: Path, monkeypatch +) -> None: + """The opened descriptor must retain one identity and stable metadata.""" + + payload = tmp_path / "payload.txt" + payload.write_bytes(b"stable") + original_fstat = os.fstat + calls = 0 + + def changed_second_fstat(file_descriptor: int): + nonlocal calls + calls += 1 + metadata = original_fstat(file_descriptor) + if calls == 2: + return _changed_metadata(metadata, st_mtime_ns=metadata.st_mtime_ns + 1) + return metadata + + monkeypatch.setattr(os, "fstat", changed_second_fstat) + assert gate._read_bounded_regular(payload, 16) is None + + +def test_bounded_reader_rejects_live_path_identity_change( + tmp_path: Path, monkeypatch +) -> None: + """The live lexical path must still identify the descriptor after reading.""" + + payload = tmp_path / "payload.txt" + payload.write_bytes(b"stable") + original_stat = os.stat + + def changed_live_stat(path, *args, **kwargs): + metadata = original_stat(path, *args, **kwargs) + if Path(path) == payload: + return _changed_metadata(metadata, st_ino=metadata.st_ino + 1) + return metadata + + monkeypatch.setattr(os, "stat", changed_live_stat) + assert gate._read_bounded_regular(payload, 16) is None + + +def test_bounded_reader_rejects_post_open_path_retarget( + tmp_path: Path, monkeypatch +) -> None: + """A lexical path that resolves differently after reading fails closed.""" + + payload = tmp_path / "payload.txt" + payload.write_bytes(b"stable") + original_resolve = Path.resolve + calls = 0 + + def retarget_after_open(path: Path, *args, **kwargs): + nonlocal calls + resolved = original_resolve(path, *args, **kwargs) + if path == payload: + calls += 1 + if calls == 2: + return payload.with_name("replacement.txt") + return resolved + + monkeypatch.setattr(Path, "resolve", retarget_after_open) + assert gate._read_bounded_regular(payload, 16) is None diff --git a/tests/test_python_native_extension_peer_gate_nested_project.py b/tests/test_python_native_extension_peer_gate_nested_project.py new file mode 100644 index 000000000..3b4305812 --- /dev/null +++ b/tests/test_python_native_extension_peer_gate_nested_project.py @@ -0,0 +1,170 @@ +"""Nested-project regressions for the PyO3 native peer-evidence classifier.""" + +from __future__ import annotations + +from pathlib import Path + +from scripts.ci import python_native_extension_peer_gate as gate + + +_PYPROJECT = """\ +[build-system] +requires = ["maturin>=1.10,<2.0"] +build-backend = "maturin" + +[tool.maturin] +bindings = "pyo3" +manifest-path = "crates/native_bridge/Cargo.toml" +module-name = "nested_package._core" +python-source = "python" +""" + +_PYTEST_LOG = """\ +============================= test session starts ============================== +collected 0 items / 1 error + +_____________ ERROR collecting tests/test_public_api.py ______________ +ImportError while importing test module '/work/services/nested/tests/test_public_api.py'. +Traceback: +tests/test_public_api.py:3: in + import nested_package._core +E ModuleNotFoundError: No module named 'nested_package._core' +!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!! +============================== 1 error in 0.20s =============================== +""" + + +def _write(path: Path, text: str) -> Path: + """Write one UTF-8 fixture and return its path.""" + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _nested_inputs(tmp_path: Path, changed: str) -> tuple[Path, Path, Path, Path]: + """Create a repository-rooted nested maturin project fixture.""" + + repository_root = tmp_path / "repository_root" + project_root = repository_root / "services" / "nested_project" + return ( + repository_root, + _write(project_root / "pytest.log", _PYTEST_LOG), + _write(project_root / "pyproject.toml", _PYPROJECT), + _write(repository_root / "changed-files.txt", changed), + ) + + +def test_nested_project_python_only_change_is_classifiable(tmp_path: Path) -> None: + """Repository-relative Python changes retain the nested project prefix.""" + + repository_root, log_path, pyproject_path, changed_files_path = _nested_inputs( + tmp_path, + "services/nested_project/python/nested_package/reporting.py\n" + "services/nested_project/tests/test_reporting.py\n", + ) + + assert gate.classify_pytest_inputs( + log_path=log_path, + pyproject_path=pyproject_path, + changed_files_path=changed_files_path, + repo_root_path=repository_root, + ) == "nested_package._core" + + +def test_nested_project_native_and_metadata_changes_block_deferral( + tmp_path: Path, +) -> None: + """Nested native paths and their exact pyproject remain blocking.""" + + for changed in ( + "services/nested_project/crates/native_bridge/README.md\n", + "services/nested_project/crates/native_bridge/src/lib.rs\n", + "services/nested_project/pyproject.toml\n", + "services/nested_project/python/nested_package/_core.pyi\n", + ): + repository_root, log_path, pyproject_path, changed_files_path = ( + _nested_inputs(tmp_path / changed.replace("/", "_"), changed) + ) + assert gate.classify_pytest_inputs( + log_path=log_path, + pyproject_path=pyproject_path, + changed_files_path=changed_files_path, + repo_root_path=repository_root, + ) is None + + +def test_repo_root_must_contain_the_pyproject(tmp_path: Path) -> None: + """A mismatched or unsafe repository root cannot classify a failure.""" + + repository_root, log_path, pyproject_path, changed_files_path = _nested_inputs( + tmp_path, + "services/nested_project/python/nested_package/reporting.py\n", + ) + outside_root = tmp_path / "outside_root" + outside_root.mkdir() + + assert gate.classify_pytest_inputs( + log_path=log_path, + pyproject_path=pyproject_path, + changed_files_path=changed_files_path, + repo_root_path=outside_root, + ) is None + + missing_root = tmp_path / "missing_root" + assert gate.classify_pytest_inputs( + log_path=log_path, + pyproject_path=pyproject_path, + changed_files_path=changed_files_path, + repo_root_path=missing_root, + ) is None + + root_link = tmp_path / "repository_link" + root_link.symlink_to(repository_root, target_is_directory=True) + assert gate.classify_pytest_inputs( + log_path=log_path, + pyproject_path=pyproject_path, + changed_files_path=changed_files_path, + repo_root_path=root_link, + ) is None + + +def test_classifier_requires_the_canonical_pyproject_filename(tmp_path: Path) -> None: + """A differently named TOML file cannot define repository trust paths.""" + + repository_root, log_path, pyproject_path, changed_files_path = _nested_inputs( + tmp_path, + "services/nested_project/python/nested_package/reporting.py\n", + ) + renamed = pyproject_path.with_name("project.toml") + pyproject_path.rename(renamed) + + assert gate.classify_pytest_inputs( + log_path=log_path, + pyproject_path=renamed, + changed_files_path=changed_files_path, + repo_root_path=repository_root, + ) is None + + +def test_root_project_default_python_source_is_repo_relative(tmp_path: Path) -> None: + """The maturin default source directory remains rooted at the repository.""" + + repository_root = tmp_path / "repository_root" + pyproject = _PYPROJECT.replace('python-source = "python"\n', "").replace( + 'manifest-path = "crates/native_bridge/Cargo.toml"', + 'manifest-path = "Cargo.toml"', + ) + log_path = _write(repository_root / "pytest.log", _PYTEST_LOG) + pyproject_path = _write(repository_root / "pyproject.toml", pyproject) + changed_files_path = _write( + repository_root / "changed-files.txt", + "nested_package/reporting.py\n", + ) + + assert gate.classify_pytest_inputs( + log_path=log_path, + pyproject_path=pyproject_path, + changed_files_path=changed_files_path, + repo_root_path=repository_root, + ) == "nested_package._core" diff --git a/tests/test_python_native_extension_peer_gate_requirements_directory.py b/tests/test_python_native_extension_peer_gate_requirements_directory.py new file mode 100644 index 000000000..c809458a9 --- /dev/null +++ b/tests/test_python_native_extension_peer_gate_requirements_directory.py @@ -0,0 +1,89 @@ +"""Requirements-directory trust-boundary regressions for PyO3 peer evidence.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scripts.ci import python_native_extension_peer_gate as gate + + +_PYPROJECT = """\ +[build-system] +requires = ["maturin>=1.10,<2.0"] +build-backend = "maturin" + +[tool.maturin] +bindings = "pyo3" +manifest-path = "crates/fast-mlsirm-py/Cargo.toml" +module-name = "fast_mlsirm._core" +python-source = "python" +""" + +_PYTEST_LOG = """\ +============================= test session starts ============================== +collected 0 items / 1 error + +_____________ ERROR collecting tests/test_mle.py ______________ +ImportError while importing test module '/work/tests/test_mle.py'. +Traceback: +tests/test_mle.py:3: in + import fast_mlsirm._core +E ModuleNotFoundError: No module named "fast_mlsirm._core" +!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!! +============================== 1 error in 0.20s =============================== +""" + + +def _write(path: Path, content: str) -> Path: + """Write one UTF-8 fixture and return its path.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return path + + +@pytest.mark.parametrize( + "changed_path", + ( + "requirements/ci.txt", + "requirements/ci.in", + "requirements/notes.txt", + "services/scoring_service/requirements/package.txt", + "services/scoring_service/requirements/package.in", + ), +) +def test_requirements_directory_changes_block_native_peer_deferral( + tmp_path: Path, + changed_path: str, +) -> None: + """Direct requirements-directory changes require current-head native builds.""" + log_path = _write(tmp_path / "pytest.log", _PYTEST_LOG) + pyproject_path = _write(tmp_path / "pyproject.toml", _PYPROJECT) + changed_files_path = _write(tmp_path / "changed-files.txt", changed_path + "\n") + + assert gate.classify_pytest_inputs( + log_path=log_path, + pyproject_path=pyproject_path, + changed_files_path=changed_files_path, + repo_root_path=tmp_path, + ) is None + + +def test_unrelated_txt_outside_requirements_directory_can_still_defer( + tmp_path: Path, +) -> None: + """A documentation text file does not become a dependency boundary by suffix.""" + log_path = _write(tmp_path / "pytest.log", _PYTEST_LOG) + pyproject_path = _write(tmp_path / "pyproject.toml", _PYPROJECT) + changed_files_path = _write( + tmp_path / "changed-files.txt", + "docs/release_notes.txt\n", + ) + + assert gate.classify_pytest_inputs( + log_path=log_path, + pyproject_path=pyproject_path, + changed_files_path=changed_files_path, + repo_root_path=tmp_path, + ) == "fast_mlsirm._core" diff --git a/tests/test_python_native_extension_peer_gate_workflow_contract.py b/tests/test_python_native_extension_peer_gate_workflow_contract.py new file mode 100644 index 000000000..7a3b99df8 --- /dev/null +++ b/tests/test_python_native_extension_peer_gate_workflow_contract.py @@ -0,0 +1,109 @@ +"""Permanent workflow contracts for the PyO3 source-only coverage boundary.""" + +from __future__ import annotations + +from pathlib import Path + + +_ROOT = Path(__file__).parents[1] +_REVIEW_WORKFLOW = _ROOT / ".github" / "workflows" / "opencode-review-dispatch.yml" +_QUALITY_WORKFLOW = ( + _ROOT + / ".github" + / "workflows" + / "python-native-extension-peer-gate-quality-ci.yml" +) +_HELPER = "scripts/ci/python_native_extension_peer_gate.py" + + +def _review_workflow() -> str: + """Return the protected OpenCode review workflow text.""" + + return _REVIEW_WORKFLOW.read_text(encoding="utf-8") + + +def _quality_workflow() -> str: + """Return the permanent PyO3 peer-gate quality workflow text.""" + + return _QUALITY_WORKFLOW.read_text(encoding="utf-8") + + +def test_failed_python_suite_uses_bounded_repo_root_aware_classifier() -> None: + """Only a real Python failure may enter the exact PyO3 classifier.""" + + workflow = _review_workflow() + assert "python_native_peer_check_required=0" in workflow + assert "classify-pytest" in workflow + assert _HELPER in workflow + assert '--repo-root "$COVERAGE_SOURCE_WORKDIR"' in workflow + assert '--pyproject "$python_native_pyproject_snapshot"' in workflow + assert '--logical-pyproject "$project_dir/pyproject.toml"' in workflow + assert "install -m 0444 --" in workflow + assert '"$project_dir/pyproject.toml"' in workflow + assert "changed_files_for_coverage" in workflow + assert "python_native_pytest_log" in workflow + assert "python_native_changed_files" in workflow + assert "if run_python_native_extension_classifier" in workflow + + +def test_source_only_native_failure_is_distinct_deferred_evidence() -> None: + """A classifier result is never serialized as ordinary passing coverage.""" + + workflow = _review_workflow() + assert "### Python native-extension source-only deferral" in workflow + assert '- Result: DEFERRED' in workflow + assert ( + "the unchanged declared PyO3 module was unavailable in the source-only " + "sandbox" in workflow + ) + assert "exact-head Python, Rust/PyO3, and package CheckRuns" in workflow + assert ( + "Python native-extension peer evidence: deferred source-only collection " + "requires successful exact-head peer checks" in workflow + ) + + +def test_approval_requires_live_exact_head_python_rust_and_package_checkruns() -> None: + """The trusted approval phase must validate all three exact-head peer checks.""" + + workflow = _review_workflow() + assert "python_native_peer_check_required" in workflow + assert "require-checks" in workflow + assert '--head-sha "$PR_HEAD_SHA"' in workflow + for requirement in ("CI::python", "CI::rust", "CI::package"): + assert f'--required-check "{requirement}"' in workflow + assert "check-runs" in workflow + assert "__typename" in workflow + assert "CheckRun" in workflow + assert "r_peer_check_required" in workflow + assert ( + "require_r_cmd_check_for_deferred_coverage" in workflow + or "R CMD check" in workflow + ) + + +def test_quality_workflow_covers_supported_pythons_and_all_contract_files() -> None: + """Python 3.10/3.14, coverage, docstrings, and integration stay permanent.""" + + workflow = _quality_workflow() + for path in ( + _HELPER, + "tests/test_python_native_extension_peer_gate.py", + "tests/test_python_native_extension_peer_gate_nested_project.py", + "tests/test_python_native_extension_peer_gate_file_safety.py", + "tests/test_python_native_extension_peer_gate_requirements_directory.py", + "tests/test_python_native_extension_peer_gate_workflow_contract.py", + ".github/workflows/opencode-review-dispatch.yml", + ".github/workflows/python-native-extension-peer-gate-quality-ci.yml", + "docs/doctoring/python-native-extension-peer-evidence.md", + "CHANGELOG.md", + ): + assert path in workflow + assert 'python-version: "3.10"' in workflow + assert 'python-version: "3.14"' in workflow + assert "--cov-branch" in workflow or "branch = True" in workflow + assert "fail_under = 100" in workflow + assert "interrogate --fail-under 100" in workflow + assert "compileall -q" in workflow + assert "actionlint" in workflow + assert '"${RUNNER_TEMP}/actionlint" -shellcheck=' in workflow diff --git a/tests/test_repository_branch_coverage_pr743_cleanup.py b/tests/test_repository_branch_coverage_pr743_cleanup.py index 0b3d56367..caaaedf94 100644 --- a/tests/test_repository_branch_coverage_pr743_cleanup.py +++ b/tests/test_repository_branch_coverage_pr743_cleanup.py @@ -29,8 +29,8 @@ def test_opencode_runtime_git_calls_use_fully_isolated_configuration() -> None: + " GIT_CONFIG_VALUE_0=/work " + chr(92) + "\n" ) - assert runtime.count(count_key) == 3 - assert runtime.count(isolated_block) == 3 + assert runtime.count(count_key) == 4 + assert runtime.count(isolated_block) == 4 def test_pr743_temporary_write_workflows_are_absent() -> None: