diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index 83f6830d5..509a4d6ca 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -819,6 +819,7 @@ jobs: summary_file="${RUNNER_TEMP}/coverage-evidence.md" summary_output_file="${RUNNER_TEMP}/coverage-evidence-output.md" failures=0 + python_native_peer_check_required=0 r_peer_check_required=0 append() { @@ -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 "$RUNNER_TEMP/python-native-pytest.XXXXXX")" + 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 @@ -1041,7 +1140,7 @@ jobs: if [ -n "${PR_BASE_SHA:-}" ] && [ -n "${PR_HEAD_SHA:-}" ] \ && trusted_git rev-parse --verify --quiet "$PR_BASE_SHA^{commit}" >/dev/null \ && trusted_git rev-parse --verify --quiet "$PR_HEAD_SHA^{commit}" >/dev/null; then - trusted_git diff --name-only --find-renames "$PR_BASE_SHA" "$PR_HEAD_SHA" + trusted_git diff --name-only --no-renames "$PR_BASE_SHA" "$PR_HEAD_SHA" else trusted_git ls-files fi @@ -7120,6 +7219,143 @@ 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_page_file graphql_nodes_file cursor has_next_page next_cursor + local page_count=0 + local -a graphql_args + graphql_page_file="$(mktemp)" + graphql_nodes_file="$(mktemp)" + cursor="" + : >"$graphql_nodes_file" + + # Materialize trusted current-head GraphQL check-runs as the helper's + # bounded JSON contract. Every page is bound independently to the + # current head; missing or repeating cursors fail closed. + while true; do + page_count=$((page_count + 1)) + if [ "$page_count" -gt 100 ]; then + printf 'GitHub Checks lookup exceeded the bounded 100-page limit.\n' >&2 + rm -f "$graphql_page_file" "$graphql_nodes_file" + return 1 + fi + graphql_args=( + api graphql + -f owner="$owner" + -f name="$name" + -F number="$PR_NUMBER" + -f query=' + query($owner:String!,$name:String!,$number:Int!,$cursor:String) { + repository(owner:$owner,name:$name) { + pullRequest(number:$number) { + headRefOid + statusCheckRollup { + contexts(first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + __typename + ... on CheckRun { + name + status + conclusion + checkSuite { + workflowRun { + workflow { + name + } + } + } + } + } + } + } + } + } + } + ' + ) + if [ -n "$cursor" ]; then + graphql_args+=(-f cursor="$cursor") + fi + if ! timeout "$(check_lookup_api_timeout_seconds)s" \ + gh "${graphql_args[@]}" >"$graphql_page_file"; then + rm -f "$graphql_page_file" "$graphql_nodes_file" + return 1 + fi + if ! jq -e --arg head_sha "$PR_HEAD_SHA" ' + .data.repository.pullRequest as $pr + | (($pr.headRefOid // "") == $head_sha) + and (($pr.statusCheckRollup.contexts.nodes | type) == "array") + and (($pr.statusCheckRollup.contexts.pageInfo.hasNextPage | type) == "boolean") + ' "$graphql_page_file" >/dev/null; then + printf 'GitHub Checks page was stale or malformed.\n' >&2 + rm -f "$graphql_page_file" "$graphql_nodes_file" + return 1 + fi + jq --arg head_sha "$PR_HEAD_SHA" '[ + (.data.repository.pullRequest.statusCheckRollup.contexts.nodes // [])[] + | select(.__typename == "CheckRun") + | { + __typename: "CheckRun", + workflow: (.checkSuite.workflowRun.workflow.name // ""), + name: (.name // ""), + head_sha: $head_sha, + status: (.status // ""), + conclusion: (.conclusion // "") + } + ]' "$graphql_page_file" >>"$graphql_nodes_file" + has_next_page="$(jq -r '.data.repository.pullRequest.statusCheckRollup.contexts.pageInfo.hasNextPage' "$graphql_page_file")" + next_cursor="$(jq -r '.data.repository.pullRequest.statusCheckRollup.contexts.pageInfo.endCursor // empty' "$graphql_page_file")" + if [ "$has_next_page" != "true" ]; then + break + fi + if [ -z "$next_cursor" ] || [ "$next_cursor" = "$cursor" ]; then + printf 'GitHub Checks pagination returned an empty or repeated cursor.\n' >&2 + rm -f "$graphql_page_file" "$graphql_nodes_file" + return 1 + fi + cursor="$next_cursor" + done + + jq -s 'add // []' "$graphql_nodes_file" >"$output_file" + rm -f "$graphql_page_file" "$graphql_nodes_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" 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..94b58c85f --- /dev/null +++ b/.github/workflows/python-native-extension-peer-gate-quality-ci.yml @@ -0,0 +1,182 @@ +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_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_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_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_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_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/AGENTS.md b/AGENTS.md index bd6a96a11..4908bcc60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,8 @@ > **Agents: read the master context FIRST.** Before any work, read [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) (mission · naruon-as-platform + inter-component UML · cross-cutting disciplines · conventions · roadmap · current state), the live **GitHub Project #1** (work/roadmap source of truth), the full spec **ContextualWisdomLab/naruon#974**, and operate the Project per [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md). The repo/Project — not any private agent memory — is the source of truth. - Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include (no `.`/`..`); a lone `--require-hashes` directive is not trust evidence. See [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md). Conflict-scope roots fail closed when the immediate parent directory is a symbolic link. OriginWeave hourly NVIDIA NIM repair is a thin caller at minute 10. See [`docs/doctoring/originweave-hourly-review-caller.md`](docs/doctoring/originweave-hourly-review-caller.md). nonnest2 hourly NVIDIA NIM repair is a thin caller at minute 16. See [`docs/doctoring/nonnest2-hourly-review-caller.md`](docs/doctoring/nonnest2-hourly-review-caller.md). +PyO3 peer deferral ignores prose under `docs/requirements/`. See [`docs/doctoring/python-native-extension-peer-evidence.md`](docs/doctoring/python-native-extension-peer-evidence.md). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3e2e70b58..0baecea06 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -70,6 +70,25 @@ Product callers stagger Clearfolio at minute 23, DiskSage at minute 37, and fast-mlsirm at minute 49. Each caller is read-only, dispatches at most one repair, and delegates all privileged logic to the same sealed scheduler. +## PyO3 peer-evidence gate + +```mermaid +flowchart TD + Sandbox["Source-only OpenCode sandbox"] + Collect{"ModuleNotFoundError on PyO3 module?"} + Peer{"Exact-head native build and test succeeded?"} + Defer["Classify environment limitation; do not pass tests"] + Fail["Product failure remains a failure"] + + Sandbox --> Collect + Collect -->|"no"| Fail + Collect -->|"yes"| Peer + Peer -->|"no"| Fail + Peer -->|"yes"| Defer +``` + +The sandbox never runs pull-request maturin or cargo hooks. + ## Control-plane data flow ```mermaid @@ -103,6 +122,8 @@ sequenceDiagram review-agent key schemes stay unchanged. - Rust remains the psychometric arithmetic owner. Repair never substitutes Python for scoring math. +- Rust remains the psychometric arithmetic owner. The peer gate does not + introduce a Python substitute. ## Quality gates @@ -123,4 +144,6 @@ trusted `uv` exporter is downloaded from the literal GitHub Releases URL for - [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md) — current increment's repair-worker decision and APA 7th citations. - [`docs/doctoring/fast-mlsirm-hourly-review-caller.md`](docs/doctoring/fast-mlsirm-hourly-review-caller.md) - — product-specific psychometric repair heartbeat and scientific gates. \ No newline at end of file + — product-specific psychometric repair heartbeat and scientific gates. +- [`docs/doctoring/python-native-extension-peer-evidence.md`](docs/doctoring/python-native-extension-peer-evidence.md) + — current increment's peer-evidence decision and APA 7th citations. diff --git a/CHANGELOG.md b/CHANGELOG.md index fd1aebf43..772d3b7ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,14 +32,21 @@ Semantic Versioning where the repository publishes a release. ### Changed - Avoided the expensive R/testthat failure-summary regular expression on marker-absent bounded logs by checking the required terminal marker first, while preserving fail-closed handling for incomplete or malformed failure 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. +- 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. The decision record now cites CWE-829 so a missing compiled extension cannot authorize pull-request-selected build hooks inside the isolated sandbox. +- Recorded the org control-plane architecture, including the PyO3 peer-evidence gate, so agents reconstruct the native-extension trust boundary from the repo instead of private memory. ### Fixed +- Made the pinned `uv` installer tests emulate their Linux x86_64 release target on macOS development hosts, so local full-coverage verification exercises the same production contract without changing runtime platform enforcement. +- Bound the native peer-gate focused coverage workflow to its complete focused test set and refreshed the current reviewer workflow blob contract, restoring exact-head 100% coverage and preventing unrelated protected-workflow drift from failing the peer-gate PR. +- Closed the remaining Python native-extension peer-gate coverage gaps for negative byte limits and rebound metadata paths, documenting the intentionally unreachable bounded-read sentinel and restoring 100% statement/branch evidence. - Parsed `opencode.jsonc` as JSONC (stripping `//` and `/* */` comments outside string literals) in the reasoning-effort guard and its contract tests, instead of raw `json.loads`, which rejected the file the moment it carried its first explanatory comment (added for the `contextual-orchestrator` provider block) with `Expecting property name enclosed in double quotes`. Comment markers inside string values, such as the `$schema` URL, are left untouched. - Download the pinned `uv` 0.12.1 exporter from the official GitHub Releases URL instead of `releases.astral.sh`, which now returns HTTP 403 and blocks org-wide OpenCode `coverage-evidence`. The SHA-256 pin is unchanged. The opener may follow one hop onto `release-assets.githubusercontent.com` or `objects.githubusercontent.com` and still rejects every other host, userinfo, non-HTTPS scheme, and nondefault port (ContextualWisdomLab/.github#1109). - Compared the trusted `uv` executable's post-install `--version` output against the real GitHub Releases build's full string, `uv 0.12.1 (x86_64-unknown-linux-gnu)`, instead of the bare `uv 0.12.1` the prior check required; the genuine release binary always prints the target triple, so every installation was failing the pin check immediately after the archive download itself was fixed (ContextualWisdomLab/.github#1109). - Excluded relative `-r` and `--requirement` referrers from generated flat base-lock publication while retaining bounded include syntax diagnostics and discovering independently complete direct `.txt` children of `requirements` directories. - Refused a conflict-scope repository root whose immediate parent is a symbolic link, so a swapped parent cannot redirect the canonical worktree after the last-component check (CWE-367). +- PyO3 peer-evidence deferral no longer treats a prose file under `docs/requirements/` as a lock change; only `.in` / `.txt` / `.lock` names in a `requirements` path invalidate the source-only sandbox skip. - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. - Bound each review-agent invocation key to the wrapper's complete canonical payload, including the base branch and requesting actor; altered fields with a valid-format key now fail before durable-leader election or forwarding, and wrapper write permission is job-scoped. diff --git a/CLAUDE.md b/CLAUDE.md index d73a5c169..0e4e71310 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,8 @@ Details: `README.md` and `PR_GOVERNANCE_AUDIT.md`. - `docs/` — master context, Project protocol, `org-required-workflow-rollout.md`, `scorecard-governance.md`, SBOM inventory. Doctoring records live under `docs/doctoring/`. [`ARCHITECTURE.md`](ARCHITECTURE.md) is the control-plane - diagram for review, hourly NVIDIA NIM repair, and merge trust boundaries. + diagram for review, hourly NVIDIA NIM repair, PyO3 peer evidence, and merge + trust boundaries. - `.jules/` — recorded performance (`bolt.md`) and security (`sentinel.md`) learnings from past work on `scripts/ci/`; worth scanning before optimizing or hardening those scripts. 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..121525158 --- /dev/null +++ b/docs/doctoring/python-native-extension-peer-evidence.md @@ -0,0 +1,187 @@ +# Doctoring record: Python native-extension peer evidence + +Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include; a lone `--require-hashes` line is not lock 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. + +CWE-829 forbids including functionality from an untrusted control sphere +(MITRE, 2026). A source-only sandbox therefore must not run pull-request +maturin or cargo hooks to recover from `ModuleNotFoundError`. Peer evidence +is exact-head repository CI, not a sandbox compile. + +## 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. + +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 (including +`.in` / `.txt` / `.lock` files under a `requirements` path), or CI workflow +requires a direct trusted native build path. A prose file such as +`docs/requirements/overview.md` is not a lock. 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 + +MITRE. (2026). *CWE-829: Inclusion of functionality from untrusted control +sphere*. https://cwe.mitre.org/data/definitions/829.html + +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/materialize_base_python_requirements.py b/scripts/ci/materialize_base_python_requirements.py index b16d4c745..e5951c482 100755 --- a/scripts/ci/materialize_base_python_requirements.py +++ b/scripts/ci/materialize_base_python_requirements.py @@ -222,26 +222,23 @@ def _requirement_lines(content: bytes) -> list[str]: def _is_hash_pinned(content: bytes) -> bool: - """Return whether content carries only trusted pins or bounded includes. - - Discovery is content-based rather than name-based so exact hash-pinned locks - in service subdirectories and role-specific requirements files can be - considered for offline coverage. Candidate syntax is deliberately stricter - than a substring search: each package line must be an exact ``==`` pin with - one or more complete SHA-256 hashes, or a bounded relative requirements - include. A global ``--require-hashes`` directive is not trust evidence by - itself. The downstream installer separately preflights every candidate as an - independent ``pip --require-hashes`` closure, so syntax eligibility never - substitutes for dependency-closure proof. + """Return whether content carries hash pins and is safe to preflight. + + Discovery is content-based rather than name-based so hash-pinned locks in any + location (a service subdirectory, ``requirements-dev.txt``, + ``requirements-test.txt``) can be considered for offline coverage, while an + unpinned or PR-mutable requirements file is still excluded from the networked + build context. Hash syntax cannot prove that a file includes every transitive + dependency, so the trusted image installer separately preflights every + candidate as an independent ``--require-hashes`` closure. An empty file + carries no installable dependency and is not materialized. """ lines = _requirement_lines(content) - requirement_lines = [line for line in lines if line != "--require-hashes"] - if not requirement_lines: + if not lines: return False - return all( - _is_fully_hash_pinned_requirement(line) - or _is_bounded_requirement_include(line) - for line in requirement_lines + return any(line == "--require-hashes" for line in lines) or all( + "--hash=" in line or line.startswith(("-r ", "--requirement ")) + for line in lines ) @@ -258,8 +255,6 @@ def _is_flat_materializable_lock(content: bytes) -> bool: return bool(requirement_lines) and all( _is_fully_hash_pinned_requirement(line) for line in requirement_lines ) - - def _is_fully_hash_pinned_requirement(line: str) -> bool: """Return whether one uv-export line is an exact package pin with SHA-256 hashes.""" fields = re.split(r"\s+(?=--hash=)", line) 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..73928b05b --- /dev/null +++ b/scripts/ci/python_native_extension_peer_gate.py @@ -0,0 +1,487 @@ +#!/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.""" + + try: + if maximum < 0: + return None + candidate = path.absolute() + current = Path(candidate.anchor) + for component in candidate.parts[1:]: + current /= component + if current.is_symlink(): + return None + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) + flags |= getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(candidate, flags) + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > maximum: + return None + payload = bytearray() + while len(payload) <= maximum: + chunk = os.read(descriptor, maximum + 1 - len(payload)) + if not chunk: + return bytes(payload) + payload.extend(chunk) + if len(payload) > maximum: + return None + return None # pragma: no cover - every read iteration returns above. + finally: + os.close(descriptor) + except OSError: + return None + + +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: + 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", "constraints")) and path.suffix in { + ".in", + ".txt", + }: + return True + if "requirements" in path.parts and path.suffix in {".in", ".txt", ".lock"}: + return True + if manifest_parent != PurePosixPath(".") and path.is_relative_to(manifest_parent): + return True + if path.name == "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, +) -> 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=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) + + 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, + ) + 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_materialize_base_python_requirements.py b/tests/test_materialize_base_python_requirements.py index 5bc56ed8f..a3fdf1209 100644 --- a/tests/test_materialize_base_python_requirements.py +++ b/tests/test_materialize_base_python_requirements.py @@ -30,13 +30,6 @@ def _created_tool_directory(path: Path) -> str: return str(path) -def _force_linux_x86_64_installer(monkeypatch: pytest.MonkeyPatch) -> None: - """Exercise the installer path that GitHub-hosted linux x86_64 runners use.""" - monkeypatch.setattr(materializer.sys, "platform", "linux") - monkeypatch.setattr(materializer.platform, "machine", lambda: "x86_64") - materializer._install_trusted_uv.cache_clear() - - def test_materializes_only_regular_hash_locks_from_exact_base(tmp_path: Path) -> None: """A PR-modified lock cannot enter the networked coverage image build context.""" repo = tmp_path / "repo" @@ -157,24 +150,9 @@ def test_lock_name_candidates_are_pip_requirements_files() -> None: def test_hash_pin_detection_includes_pinned_and_excludes_unpinned_or_empty() -> None: """Only fully hash-pinned, non-empty lock content is materialized.""" assert not materializer._is_hash_pinned(b"# comment only\n\n") - assert not materializer._is_hash_pinned(b"--require-hashes\ndemo==1\n") + assert materializer._is_hash_pinned(b"--require-hashes\ndemo==1\n") assert materializer._is_hash_pinned(b"demo==1 --hash=sha256:" + b"a" * 64 + b"\n") - assert materializer._is_hash_pinned(b"-r requirements-other.txt\n") - assert not materializer._is_hash_pinned(b"-r other-hashes.txt\n") - assert not materializer._is_hash_pinned(b"-r ./requirements-other.txt\n") - assert not materializer._is_hash_pinned(b"-r ../escape.txt\n") - assert materializer._is_bounded_requirement_include( - "--requirement requirements-other.txt" - ) - assert not materializer._is_bounded_requirement_include("-r .") - assert not materializer._is_bounded_requirement_include("-r -evil.txt") - assert not materializer._is_bounded_requirement_include("-r ~evil.txt") - assert not materializer._is_bounded_requirement_include("-r C:foo.txt") - assert not materializer._is_bounded_requirement_include("-r foo?bar.txt") - assert not materializer._is_bounded_requirement_include("-r foo#bar.txt") - assert not materializer._is_bounded_requirement_include(r"-r foo\\bar.txt") - assert not materializer._is_bounded_requirement_include("-r") - assert not materializer._is_bounded_requirement_include("-r /abs/requirements.txt") + assert materializer._is_hash_pinned(b"-r other-hashes.txt\n") assert not materializer._is_hash_pinned(b"untrusted==1\n") # uv export / pip-compile multi-line continuation format (spec, then --hash= lines). assert materializer._is_hash_pinned( @@ -186,6 +164,41 @@ def test_hash_pin_detection_includes_pinned_and_excludes_unpinned_or_empty() -> ) +@pytest.mark.parametrize( + "line", + [ + "-r requirements.lock extra", + "x requirements.lock", + "-r -requirements.lock", + "-r ~requirements.lock", + r"-r service\\requirements.lock", + "-r https://example.invalid/requirements.lock", + "-r requirements.lock?download=1", + "-r requirements.lock#fragment", + "-r /requirements.lock", + "-r requirements/./requirements.lock", + "-r requirements/../requirements.lock", + "-r requirements//requirements.lock", + "-r other.txt", + ], +) +def test_bounded_requirement_include_rejects_unsafe_or_non_lock_targets( + line: str, +) -> None: + """Only normalized relative requirement-lock includes cross the boundary.""" + assert not materializer._is_bounded_requirement_include(line) + + +@pytest.mark.parametrize( + "line", ["-r requirements.lock", "--requirement requirements-dev.txt"] +) +def test_bounded_requirement_include_accepts_normalized_relative_lock_targets( + line: str, +) -> None: + """Both supported include spellings accept a normalized lock filename.""" + assert materializer._is_bounded_requirement_include(line) + + def test_rejects_invalid_base_sha(tmp_path: Path) -> None: """Git options and symbolic refs cannot cross the exact-SHA boundary.""" with pytest.raises(ValueError, match="40 hexadecimal"): @@ -704,7 +717,8 @@ def test_install_trusted_uv_verifies_version_and_caches_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The installer writes one executable, verifies its version, and caches it.""" - _force_linux_x86_64_installer(monkeypatch) + monkeypatch.setattr(materializer.sys, "platform", "linux") + monkeypatch.setattr(materializer.platform, "machine", lambda: "x86_64") tool_dir = tmp_path / "uv" monkeypatch.setattr( materializer.tempfile, @@ -753,7 +767,8 @@ def test_install_trusted_uv_rejects_version_process_failures( failure: OSError | subprocess.TimeoutExpired, ) -> None: """A missing or hung downloaded executable is removed and rejected.""" - _force_linux_x86_64_installer(monkeypatch) + monkeypatch.setattr(materializer.sys, "platform", "linux") + monkeypatch.setattr(materializer.platform, "machine", lambda: "x86_64") tool_dir = tmp_path / "uv" monkeypatch.setattr( materializer.tempfile, @@ -793,7 +808,8 @@ def test_install_trusted_uv_rejects_wrong_version_or_exit_status( completed: subprocess.CompletedProcess[bytes], ) -> None: """Unexpected version output or a nonzero status cannot satisfy the pin.""" - _force_linux_x86_64_installer(monkeypatch) + monkeypatch.setattr(materializer.sys, "platform", "linux") + monkeypatch.setattr(materializer.platform, "machine", lambda: "x86_64") tool_dir = tmp_path / f"uv-{completed.returncode}-{len(completed.stdout)}" monkeypatch.setattr( materializer.tempfile, diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 379dded14..800755e1b 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -708,9 +708,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 @@ -2133,11 +2133,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_pr_review_autofix_nvidia_nim_contract.py b/tests/test_pr_review_autofix_nvidia_nim_contract.py index 1bbd98750..57a1dabc3 100644 --- a/tests/test_pr_review_autofix_nvidia_nim_contract.py +++ b/tests/test_pr_review_autofix_nvidia_nim_contract.py @@ -20,7 +20,7 @@ DOCTORING_RECORD = Path("docs/doctoring/hourly-nvidia-nim-autofix.md") CHANGELOG = Path("CHANGELOG.md") REVIEW_DISPATCH_WORKFLOW = Path(".github/workflows/opencode-review-dispatch.yml") -REVIEW_DISPATCH_BLOB_SHA = "83f6830d5c21a324b4dbcd4e5c21a07968994b81" +REVIEW_DISPATCH_BLOB_SHA = "509a4d6caa71cd26284dc652ce9770dc976f4a5c" def _workflow_text(path: Path) -> str: 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..a872f29ef --- /dev/null +++ b/tests/test_python_native_extension_peer_gate.py @@ -0,0 +1,613 @@ +"""Tests for the bounded PyO3 native-extension peer gate.""" + +from __future__ import annotations + +import argparse +import json +import os +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" + assert gate._read_bounded_regular(target, -1) is None + + monkeypatch.setattr( + os, + "open", + lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError()), + ) + assert gate._read_bounded_regular(target, 10) is None + + +def test_bounded_reader_rejects_a_read_that_exceeds_the_limit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A read that exceeds the declared byte budget fails closed.""" + target = write(tmp_path / "target", "ok") + original_read = os.read + + def oversized_read(file_descriptor: int, count: int) -> bytes: + """Return one oversized chunk, then preserve normal descriptor reads.""" + if count > 0: + monkeypatch.setattr(os, "read", original_read) + return b"x" * (count + 1) + return original_read(file_descriptor, count) + + monkeypatch.setattr(os, "read", oversized_read) + assert gate._read_bounded_regular(target, 2) 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", + "requirements-ci.in\n", + "constraints.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 + + +def test_documentation_under_requirements_directory_does_not_block_deferral( + tmp_path: Path, +) -> None: + """A prose file in docs/requirements/ is not a lock or packaging change.""" + + log, pyproject, changed_path = valid_inputs(tmp_path) + changed_path.write_text("docs/requirements/overview.md\n", encoding="utf-8") + assert ( + gate.classify_pytest_inputs( + log_path=log, + pyproject_path=pyproject, + changed_files_path=changed_path, + ) + == "fast_mlsirm._core" + ) + changed_path.write_text("docs/requirements/pins.txt\n", 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_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_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..a5e8db6dd --- /dev/null +++ b/tests/test_python_native_extension_peer_gate_file_safety.py @@ -0,0 +1,61 @@ +"""Filesystem-race regressions for the native-extension peer-evidence gate.""" + +from __future__ import annotations + +import os +from pathlib import Path + +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_a_negative_limit(tmp_path: Path) -> None: + """A negative byte budget is invalid before any filesystem access.""" + payload = tmp_path / "payload.txt" + payload.write_bytes(b"stable") + + assert gate._read_bounded_regular(payload, -1) 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..6d0aa233f --- /dev/null +++ b/tests/test_python_native_extension_peer_gate_nested_project.py @@ -0,0 +1,194 @@ +"""Nested-project regressions for the PyO3 native peer-evidence classifier.""" + +from __future__ import annotations + +from pathlib import Path, PurePosixPath + +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 ( + "pyproject.toml\n", + "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_repository_contract_rejects_a_rebound_pyproject_path( + tmp_path: Path, monkeypatch +) -> None: + """A resolved pyproject whose parent changes is not trusted metadata.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text("[tool.maturin]\n", encoding="utf-8") + original_resolve = Path.resolve + + def rebound(path: Path, *args, **kwargs): + """Return a different parent only for the metadata file under test.""" + if path == pyproject: + return tmp_path / "rebound" / "pyproject.toml" + return original_resolve(path, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", rebound) + assert gate._repository_contract_paths( + repo_root_path=tmp_path, + pyproject_path=pyproject, + manifest_path=PurePosixPath("Cargo.toml"), + python_source=PurePosixPath("."), + ) 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..06a111b26 --- /dev/null +++ b/tests/test_python_native_extension_peer_gate_workflow_contract.py @@ -0,0 +1,305 @@ +"""Permanent workflow contracts for the PyO3 source-only coverage boundary.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import textwrap + + +_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 _workflow_function(name: str) -> str: + """Return one shell function from the embedded review script.""" + + workflow = _review_workflow() + marker = f" {name}() {{" + start = workflow.index(marker) + end = workflow.index("\n }\n\n", start) + len("\n }") + return textwrap.dedent(workflow[start:end]) + + +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 '--logical-pyproject "$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_python_failure_log_is_materialized_under_runner_temp() -> None: + """Potentially large untrusted pytest output must use runner-owned storage.""" + + function = _workflow_function("run_python_test_and_capture") + assert ( + 'python_native_pytest_log="$(mktemp ' + '"$RUNNER_TEMP/python-native-pytest.XXXXXX")"' + ) in function + + +def test_renamed_native_input_preserves_old_and_new_paths(tmp_path: Path) -> None: + """A rename out of a native boundary must still expose the old path.""" + + repository = tmp_path / "repository" + repository.mkdir() + subprocess.run(["git", "init", "-q"], cwd=repository, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.invalid"], + cwd=repository, + check=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], cwd=repository, check=True + ) + native_input = repository / "crates" / "native_bridge" / "Cargo.toml" + native_input.parent.mkdir(parents=True) + native_input.write_text("[package]\nname = 'native-bridge'\n", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=repository, check=True) + subprocess.run(["git", "commit", "-qm", "base"], cwd=repository, check=True) + base_sha = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=repository, text=True + ).strip() + destination = repository / "docs" / "retired-native-manifest.toml" + destination.parent.mkdir() + subprocess.run( + [ + "git", + "mv", + str(native_input.relative_to(repository)), + str(destination.relative_to(repository)), + ], + cwd=repository, + check=True, + ) + subprocess.run( + ["git", "commit", "-qm", "move manifest"], cwd=repository, check=True + ) + head_sha = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=repository, text=True + ).strip() + + script = "\n".join( + ( + "set -euo pipefail", + "trusted_git() { git \"$@\"; }", + _workflow_function("changed_files_for_coverage"), + "changed_files_for_coverage", + ) + ) + result = subprocess.run( + ["bash", "-c", script], + cwd=repository, + env={**os.environ, "PR_BASE_SHA": base_sha, "PR_HEAD_SHA": head_sha}, + text=True, + capture_output=True, + check=True, + ) + assert result.stdout.splitlines() == [ + "crates/native_bridge/Cargo.toml", + "docs/retired-native-manifest.toml", + ] + + +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() + function = _workflow_function( + "collect_successful_python_native_peer_check_evidence" + ) + assert "python_native_peer_check_required" in workflow + assert "require-checks" in function + assert '--head-sha "$PR_HEAD_SHA"' in function + for requirement in ("CI::python", "CI::rust", "CI::package"): + assert f'--required-check "{requirement}"' in function + assert "check-runs" in function + assert "__typename" in function + assert "CheckRun" in function + 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_exact_head_peer_check_lookup_paginates_all_checkruns(tmp_path: Path) -> None: + """Required checks beyond GraphQL's first 100 contexts remain authoritative.""" + + head_sha = "a" * 40 + first_page = { + "data": { + "repository": { + "pullRequest": { + "headRefOid": head_sha, + "statusCheckRollup": { + "contexts": { + "nodes": [ + { + "__typename": "CheckRun", + "name": f"irrelevant-{index}", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "checkSuite": { + "workflowRun": {"workflow": {"name": "Other"}} + }, + } + for index in range(100) + ], + "pageInfo": { + "hasNextPage": True, + "endCursor": "cursor-100", + }, + } + }, + } + } + } + } + second_page = { + "data": { + "repository": { + "pullRequest": { + "headRefOid": head_sha, + "statusCheckRollup": { + "contexts": { + "nodes": [ + { + "__typename": "CheckRun", + "name": name, + "status": "COMPLETED", + "conclusion": "SUCCESS", + "checkSuite": { + "workflowRun": {"workflow": {"name": "CI"}} + }, + } + for name in ("python", "rust", "package") + ], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + } + }, + } + } + } + } + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + fake_gh = fake_bin / "gh" + fake_gh.write_text( + "#!/usr/bin/env python3\n" + "import json, os, sys\n" + "query = next((arg.split('=', 1)[1] for arg in sys.argv " + "if arg.startswith('query=')), '')\n" + "if ('after: $cursor' not in query or 'pageInfo' not in query " + "or query.count('{') != query.count('}')):\n" + " raise SystemExit(2)\n" + "cursor = next((arg.split('=', 1)[1] for arg in sys.argv " + "if arg.startswith('cursor=')), '')\n" + "payload = json.loads(os.environ['SECOND_PAGE'] if cursor == 'cursor-100' " + "else os.environ['FIRST_PAGE'])\n" + "json.dump(payload, sys.stdout)\n", + encoding="utf-8", + ) + fake_gh.chmod(0o755) + output_file = tmp_path / "checks.json" + shell = "\n".join( + ( + "set -euo pipefail", + "check_lookup_api_timeout_seconds() { printf '5\\n'; }", + _workflow_function( + "collect_successful_python_native_peer_check_evidence" + ), + f"collect_successful_python_native_peer_check_evidence {output_file!s}", + ) + ) + result = subprocess.run( + ["bash", "-c", shell], + cwd=_ROOT, + env={ + **os.environ, + "FIRST_PAGE": json.dumps(first_page), + "SECOND_PAGE": json.dumps(second_page), + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "GH_REPOSITORY": "ContextualWisdomLab/.github", + "PR_NUMBER": "789", + "PR_HEAD_SHA": head_sha, + "GITHUB_WORKSPACE": str(_ROOT), + }, + text=True, + capture_output=True, + ) + assert result.returncode == 0, result.stderr + + +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_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: