From 14dcecb8faeb36d0b7c3a5909e822c21cc788643 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Tue, 18 Aug 2026 07:27:35 +0400 Subject: [PATCH 1/3] chore: bump rhiza to v1.3.3 --- .rhiza/template.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.rhiza/template.yml b/.rhiza/template.yml index 798cfc9..0e77192 100644 --- a/.rhiza/template.yml +++ b/.rhiza/template.yml @@ -1,5 +1,5 @@ repository: "jebel-quant/rhiza" -ref: "v0.18.8" +ref: "v1.3.3" profiles: - github-project From f6204360c2e12f68b3bf0ee321bf8a9c9c776a61 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Tue, 18 Aug 2026 07:27:35 +0400 Subject: [PATCH 2/3] chore: apply rhiza sync v1.3.3 The tail of the v0.18.8 -> v1.3.3 sync: the rhiza_*.yml callers repinned from @v0.19.9 to @v1.3.3, three new workflows (fuzzing, mutation, scorecard), the pre-commit and bandit configs, the ruff rule set, TESTS.md and the lock. Everything separable has been separated and sent upstream: repo metadata (#91), the .rhiza/tests layout (#92), the make layer and the .rhiza/requirements removal (#93), the packaging test and pytest.ini (#94), and the inert config and data files (#95). What is left needs the v1.3.3 make layer underneath it, or in ruff.toml's case needs source changes first. Co-Authored-By: Claude Opus 5 (1M context) --- .bandit | 17 +++ .github/workflows/rhiza_benchmark.yml | 2 +- .github/workflows/rhiza_book.yml | 13 +- .github/workflows/rhiza_ci.yml | 7 +- .github/workflows/rhiza_codeql.yml | 10 +- .github/workflows/rhiza_fuzzing.yml | 41 ++++++ .github/workflows/rhiza_marimo.yml | 2 +- .github/workflows/rhiza_mutation.yml | 50 ++++++++ .github/workflows/rhiza_release.yml | 176 +++++++++++++++++++------- .github/workflows/rhiza_scorecard.yml | 45 +++++++ .github/workflows/rhiza_weekly.yml | 2 +- .pre-commit-config.yaml | 34 ++++- .rhiza/template.lock | 60 ++++----- docs/development/TESTS.md | 12 +- ruff.toml | 59 ++++++--- 15 files changed, 411 insertions(+), 119 deletions(-) create mode 100644 .github/workflows/rhiza_fuzzing.yml create mode 100644 .github/workflows/rhiza_mutation.yml create mode 100644 .github/workflows/rhiza_scorecard.yml diff --git a/.bandit b/.bandit index a1be520..bdf0057 100644 --- a/.bandit +++ b/.bandit @@ -1,2 +1,19 @@ +# Bandit configuration. This file — not the pre-commit hook's args — is the +# single source of truth for bandit's scope, because it is the only part any +# other runner can see. CodeFactor, IDE plugins and a contributor typing +# `bandit -r .` all read `.bandit` and none of them read our hook args, so +# scope kept in the args made every external analyser disagree with CI (#1493). +# +# Both spellings of each path are listed deliberately. Bandit matches an +# exclude entry against the path string it is handed, and that string depends on +# how it was invoked: a recursive `bandit -r .` discovers `./tests/foo.py`, +# whereas pre-commit passes `tests/foo.py`. So `./tests` alone silently covers +# only the recursive case and `tests` alone only the pre-commit case — a +# one-spelling list looks correct and half-works. Verified in +# tests/security/test_security_patterns.py, which runs bandit both ways. +# +# Note these are *added* to bandit's own defaults (.git, __pycache__, .tox, +# .eggs, …), so those need no repeating here. [bandit] +exclude = tests,./tests,.rhiza/tests,./.rhiza/tests,.venv,./.venv skips = B101 diff --git a/.github/workflows/rhiza_benchmark.yml b/.github/workflows/rhiza_benchmark.yml index 40ef5fb..6e68d9c 100644 --- a/.github/workflows/rhiza_benchmark.yml +++ b/.github/workflows/rhiza_benchmark.yml @@ -20,5 +20,5 @@ on: jobs: benchmark: - uses: jebel-quant/rhiza/.github/workflows/rhiza_benchmark.yml@v0.19.9 + uses: jebel-quant/rhiza/.github/workflows/rhiza_benchmark.yml@v1.3.3 secrets: inherit diff --git a/.github/workflows/rhiza_book.yml b/.github/workflows/rhiza_book.yml index e31dac8..a2aa808 100644 --- a/.github/workflows/rhiza_book.yml +++ b/.github/workflows/rhiza_book.yml @@ -6,7 +6,10 @@ # It combines API documentation, test coverage reports, test results, and # interactive notebooks into a single GitHub Pages site. # -# Trigger: This workflow runs on every push to the main or master branch +# Trigger: This workflow runs on every push (any branch), so every commit +# validates that the book still builds. The reusable workflow deploys +# to GitHub Pages only from the repository's default branch and never +# from a fork; other branches build and upload an artifact only. # # Components: # - 📓 Process Marimo notebooks @@ -19,12 +22,14 @@ name: "(RHIZA) BOOK" on: push: branches: - - main - - master + - '**' + +permissions: + contents: read jobs: book: - uses: jebel-quant/rhiza/.github/workflows/rhiza_book.yml@v0.19.9 + uses: jebel-quant/rhiza/.github/workflows/rhiza_book.yml@v1.3.3 secrets: inherit permissions: contents: read diff --git a/.github/workflows/rhiza_ci.yml b/.github/workflows/rhiza_ci.yml index a54003d..9f4b3d0 100644 --- a/.github/workflows/rhiza_ci.yml +++ b/.github/workflows/rhiza_ci.yml @@ -7,6 +7,11 @@ # pre-commit hooks, verify documentation coverage, validate the # project, run security scans, and check license compliance. # +# Python version matrix source of truth: +# - Implemented in the reusable workflow called below +# - Generated from `Programming Language :: Python :: 3.x` classifiers in pyproject.toml +# - Adding/removing classifiers updates CI Python coverage automatically +# # Trigger: On push and pull_request. name: "(RHIZA) CI" @@ -21,5 +26,5 @@ on: jobs: ci: - uses: jebel-quant/rhiza/.github/workflows/rhiza_ci.yml@v0.19.9 + uses: jebel-quant/rhiza/.github/workflows/rhiza_ci.yml@v1.3.3 secrets: inherit diff --git a/.github/workflows/rhiza_codeql.yml b/.github/workflows/rhiza_codeql.yml index 56b5bdd..1c4161f 100644 --- a/.github/workflows/rhiza_codeql.yml +++ b/.github/workflows/rhiza_codeql.yml @@ -14,9 +14,6 @@ name: "(RHIZA) CODEQL" permissions: - security-events: write - packages: read - actions: read contents: read on: @@ -29,5 +26,10 @@ on: jobs: codeql: - uses: jebel-quant/rhiza/.github/workflows/rhiza_codeql.yml@v0.19.9 + uses: jebel-quant/rhiza/.github/workflows/rhiza_codeql.yml@v1.3.3 secrets: inherit + permissions: + security-events: write # Upload CodeQL results to code scanning + packages: read + actions: read + contents: read diff --git a/.github/workflows/rhiza_fuzzing.yml b/.github/workflows/rhiza_fuzzing.yml new file mode 100644 index 0000000..a76a0dd --- /dev/null +++ b/.github/workflows/rhiza_fuzzing.yml @@ -0,0 +1,41 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# Workflow: ClusterFuzzLite fuzzing +# +# Purpose: Run coverage-guided fuzzing for the repository's Python security +# parsing utilities. Pull requests run short code-change fuzzing, +# while main-branch pushes and the weekly schedule run batch fuzzing. +# +# Opt-in: fuzzing is OFF by default and very optional. Set the +# repository variable `FUZZING_ENABLED` to 'true' to run it (a +# .clusterfuzzlite/ config must also be present); otherwise the +# reusable workflow skips fuzzing (the run stays green). +# +# Thin stub: the fuzzing logic lives in the reusable workflow in +# jebel-quant/rhiza; this file only wires up the triggers. +# +# Trigger: Pull requests, pushes to main/master, weekly schedule, and manual +# dispatch. + +name: "(RHIZA) FUZZING" + +on: + pull_request: + branches: [ "main", "master" ] + push: + branches: [ "main", "master" ] + schedule: + - cron: '17 3 * * 6' + workflow_dispatch: + +permissions: + contents: read + +jobs: + fuzzing: + uses: jebel-quant/rhiza/.github/workflows/rhiza_fuzzing.yml@v1.3.3 + secrets: inherit + permissions: + contents: read + security-events: write # Upload fuzzing SARIF to code scanning diff --git a/.github/workflows/rhiza_marimo.yml b/.github/workflows/rhiza_marimo.yml index 4fb4866..833cbbb 100644 --- a/.github/workflows/rhiza_marimo.yml +++ b/.github/workflows/rhiza_marimo.yml @@ -28,5 +28,5 @@ on: jobs: marimo: - uses: jebel-quant/rhiza/.github/workflows/rhiza_marimo.yml@v0.19.9 + uses: jebel-quant/rhiza/.github/workflows/rhiza_marimo.yml@v1.3.3 secrets: inherit diff --git a/.github/workflows/rhiza_mutation.yml b/.github/workflows/rhiza_mutation.yml new file mode 100644 index 0000000..32ac178 --- /dev/null +++ b/.github/workflows/rhiza_mutation.yml @@ -0,0 +1,50 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# Workflow: Mutation Testing +# +# Purpose: Measure test *assertion strength* with mutmut. 100% line/branch +# coverage proves code is executed, not that a wrong result would be +# caught; surviving mutants reveal assertions that are too weak. +# +# Opt-in: mutation testing is OFF by default and very optional. Set the +# repository variable `MUTATION_ENABLED` to 'true' to run it; otherwise +# the reusable workflow skips the mutation job (the run stays green). +# +# Enforced gate (when enabled): mutation runs are required and fail when +# mutants survive (100% mutation score threshold in the reusable +# workflow). +# +# Thin stub: the mutation logic and the opt-in gate live in the +# reusable workflow in jebel-quant/rhiza; this file only wires up the +# triggers. +# +# Published mutation badge URL (when enabled): +# https://.github.io//mutation-badge.svg +# +# Trigger: Weekly schedule, manual dispatch, and pull_request so mutation +# testing is included in PR CI. + +name: "(RHIZA) MUTATION" + +permissions: + contents: read + +on: + pull_request: + schedule: + - cron: "0 9 * * 1" # Monday 09:00 UTC (after the rhiza weekly job at 08:00) + workflow_dispatch: + +jobs: + mutation: + # Opt-in gate: mutation testing is OFF by default. The job only runs when + # this repo sets the `MUTATION_ENABLED` variable to 'true'. Gating here in + # the caller keeps it optional regardless of the pinned reusable workflow. + if: ${{ vars.MUTATION_ENABLED == 'true' }} + uses: jebel-quant/rhiza/.github/workflows/rhiza_mutation.yml@v1.3.3 + secrets: inherit + permissions: + contents: read + pages: write # publish-mutation-badge deploys the badge to Pages + id-token: write # publish-mutation-badge needs OIDC for the Pages deploy diff --git a/.github/workflows/rhiza_release.yml b/.github/workflows/rhiza_release.yml index 0a34b5d..e255ef4 100644 --- a/.github/workflows/rhiza_release.yml +++ b/.github/workflows/rhiza_release.yml @@ -21,11 +21,15 @@ # 2. 🏗️ Build - Build Python package with Hatch (if [build-system] is defined in pyproject.toml) # 3. 📦 Generate SBOM - Create Software Bill of Materials (CycloneDX format) # 4. 📝 Draft Release - Create draft GitHub release with build artifacts and SBOM -# 5. 📄 Update CHANGELOG - Generate and commit CHANGELOG.md to the default branch -# 6. 🚀 Publish to PyPI - Publish package using OIDC or custom feed -# 7. 📦 Generate Conda Recipe - Generate conda-forge recipe with grayskull (conditional) -# 8. 🐳 Publish Devcontainer - Build and publish devcontainer image (conditional) -# 9. ✅ Finalize Release - Publish the GitHub release with links +# 5. 🚀 Publish to PyPI - Publish package using OIDC or custom feed +# 6. 📦 Generate Conda Recipe - Generate conda-forge recipe with grayskull (conditional) +# 7. 🐳 Publish Devcontainer - Build and publish devcontainer image (conditional) +# 8. ✅ Finalize Release - Publish the GitHub release with links +# +# 📄 CHANGELOG: not updated here. The release process (rhiza-claude `/release`) +# folds a freshly generated CHANGELOG.md into the version-bump commit before the +# tag is pushed, so the tagged commit already carries the changelog — no separate +# post-tag commit. # # 📦 SBOM Generation: # - Generated using CycloneDX format (industry standard for software supply chain security) @@ -102,16 +106,23 @@ on: PYPI_TOKEN: required: false +# Queue runs instead of cancelling: a release or sync must never be +# interrupted mid-publish or mid-push. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +# Least-privilege default: every job below re-declares exactly the write +# scopes it needs (Scorecard Token-Permissions). Top-level stays read-only. permissions: - contents: write # Needed to create releases - id-token: write # Needed for OIDC authentication with PyPI - packages: write # Needed to publish devcontainer image - attestations: write # Needed for SLSA provenance attestations (public repos only) + contents: read jobs: tag: name: Validate Tag runs-on: ubuntu-latest + permissions: + contents: read # Validation only: reads tags and release state outputs: tag: ${{ steps.set_tag.outputs.tag }} steps: @@ -145,6 +156,48 @@ jobs: fi fi + - name: Ensure the tagged commit is reachable from a branch + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.set_tag.outputs.tag }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + # Backstop for issue #1454: a release cut on a branch that is then + # squash-merged leaves the tag on the pre-squash commit, and the squash + # puts the same content on the default branch under a new SHA. The tag is + # then permanently orphaned — no branch contains it, `git describe` skips + # the release, and a git-cliff regeneration silently deletes that version's + # CHANGELOG section because it cannot place a boundary at an unreachable + # tag. Publishing from such a tag is never intended, so refuse it. + # The checkout above uses fetch-depth: 0; this fetch only adds the remote + # branch refs, which a tag-push checkout does not need otherwise. + git fetch --no-tags --quiet origin '+refs/heads/*:refs/remotes/origin/*' + + if [ -z "$DEFAULT_BRANCH" ]; then + DEFAULT_BRANCH=$(gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name') + fi + if ! git rev-parse --verify --quiet "refs/remotes/origin/$DEFAULT_BRANCH" >/dev/null; then + echo "::error::Cannot resolve the default branch 'origin/$DEFAULT_BRANCH' — refusing to release without a reachability check." + exit 1 + fi + + COMMIT=$(git rev-parse "$TAG^{commit}") + if git merge-base --is-ancestor "$COMMIT" "refs/remotes/origin/$DEFAULT_BRANCH"; then + echo "✅ $TAG ($COMMIT) is an ancestor of $DEFAULT_BRANCH" + exit 0 + fi + + # Reachable from some other branch: a maintenance/hotfix release. Legitimate, + # but a changelog regenerated from the default branch still cannot see it. + BRANCHES=$(git branch -r --contains "$COMMIT" --format='%(refname:short)') + if [ -n "$BRANCHES" ]; then + echo "::warning::Tag $TAG is not an ancestor of $DEFAULT_BRANCH; it is contained in: $(echo "$BRANCHES" | tr '\n' ' '). A CHANGELOG regenerated from $DEFAULT_BRANCH will not include this release." + exit 0 + fi + + echo "::error::Tag $TAG points at $COMMIT, which no branch contains. It is most likely a pre-squash commit from a squash-merged release branch: re-tag the merged commit on $DEFAULT_BRANCH and delete this tag (issue #1454)." + exit 1 + - name: Install uv uses: astral-sh/setup-uv@v7.6.0 @@ -195,6 +248,10 @@ jobs: name: Build runs-on: ubuntu-latest needs: tag + permissions: + contents: read + id-token: write # OIDC for attestation signing + attestations: write # SLSA provenance + SBOM attestations (public repos only) steps: - name: Checkout Code uses: actions/checkout@v6.1.0 @@ -207,7 +264,7 @@ jobs: version: "0.11.16" - name: Configure git auth for private packages - uses: jebel-quant/rhiza/.github/actions/configure-git-auth@v0.19.9 + uses: jebel-quant/actions/configure-git-auth@6d52725ca371d609489c5b50236965ad70bbe528 # v1 with: token: ${{ secrets.GH_PAT }} @@ -270,12 +327,22 @@ jobs: - name: Attest SBOM # Attest only the JSON format as it's the canonical machine-readable format. # The XML format is provided for compatibility but doesn't need separate attestation. + id: attest-sbom if: hashFiles('pyproject.toml') != '' && github.event.repository.private == false uses: actions/attest@v4.2.0 with: subject-path: sbom.cdx.json sbom-path: sbom.cdx.json + - name: Stage SBOM attestation for the GitHub release + # Attach the SBOM's Sigstore attestation bundle to the release as a + # recognised signature asset (*.sigstore.json). Non-buildable repos + # (no [build-system], so no dist/*.intoto.jsonl provenance) would + # otherwise ship releases without any signature asset, which fails + # OpenSSF Scorecard's Signed-Releases check. + if: hashFiles('pyproject.toml') != '' && github.event.repository.private == false + run: cp "${{ steps.attest-sbom.outputs.bundle-path }}" sbom.cdx.json.sigstore.json + - name: Upload SBOM artifacts if: hashFiles('pyproject.toml') != '' uses: actions/upload-artifact@v7.0.1 @@ -284,13 +351,22 @@ jobs: path: | sbom.cdx.json sbom.cdx.xml + sbom.cdx.json.sigstore.json - name: Generate SLSA provenance attestations + id: provenance if: steps.buildable.outputs.buildable == 'true' && github.event.repository.private == false - uses: actions/attest-build-provenance@v4 + uses: actions/attest-build-provenance@v4.1.0 with: subject-path: dist/* + - name: Stage provenance bundle for the GitHub release + # Attach the SLSA provenance bundle to the release as a recognised + # signature asset (*.intoto.jsonl) so consumers — and OpenSSF + # Scorecard's Signed-Releases check — can verify the artifacts. + if: steps.buildable.outputs.buildable == 'true' && github.event.repository.private == false + run: cp "${{ steps.provenance.outputs.bundle-path }}" dist/provenance.intoto.jsonl + - name: Upload dist artifact if: steps.buildable.outputs.buildable == 'true' uses: actions/upload-artifact@v7.0.1 @@ -303,6 +379,8 @@ jobs: name: Draft GitHub Release runs-on: ubuntu-latest needs: [tag, build] + permissions: + contents: write # Needed to create the GitHub release and upload artifacts steps: - name: Checkout Code @@ -324,6 +402,15 @@ jobs: path: sbom continue-on-error: true + - name: Download dist artifact + # Brings in provenance.intoto.jsonl (and the built distributions) staged + # by the build job, so the SLSA provenance ships as a release asset. + uses: actions/download-artifact@v8.0.1 + with: + name: dist + path: dist + continue-on-error: true + - name: Create GitHub Release with artifacts uses: ncipollo/release-action@v1.21.0 with: @@ -332,35 +419,7 @@ jobs: bodyFile: RELEASE_NOTES.md draft: true allowUpdates: true - artifacts: "sbom/*" - - update-changelog: - name: Update CHANGELOG.md - runs-on: ubuntu-latest - needs: [tag, draft-release] - steps: - - name: Checkout Default Branch - uses: actions/checkout@v6.1.0 - with: - ref: ${{ github.event.repository.default_branch }} - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Install uv - uses: astral-sh/setup-uv@v7.6.0 - - - name: Generate CHANGELOG.md with git-cliff - run: uvx git-cliff --output CHANGELOG.md - - - name: Commit and push CHANGELOG.md - env: - TAG: ${{ needs.tag.outputs.tag }} - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add CHANGELOG.md - git diff --staged --quiet || git commit -m "chore: update CHANGELOG.md for $TAG [skip ci]" - git push origin ${{ github.event.repository.default_branch }} + artifacts: "sbom/*,dist/provenance.intoto.jsonl" # Decide at step-level whether to publish pypi: @@ -368,6 +427,9 @@ jobs: runs-on: ubuntu-latest environment: release needs: [tag, build, draft-release] + permissions: + contents: read + id-token: write # OIDC Trusted Publishing to PyPI (no stored credentials) outputs: should_publish: ${{ steps.check_dist.outputs.should_publish }} @@ -400,11 +462,18 @@ jobs: fi cat "$GITHUB_OUTPUT" + - name: Remove non-distribution files before publish + # The dist artifact also carries the SLSA provenance bundle (staged for + # the GitHub release). twine/pypi-publish rejects it as an unknown + # distribution format, so strip it here before publishing. + if: ${{ steps.check_dist.outputs.should_publish == 'true' }} + run: rm -f dist/*.intoto.jsonl + # this should not take place, as "Private :: Do Not Upload" set in pyproject.toml # repository-url and password only used for custom feeds, not for PyPI with OIDC - name: Publish to PyPI if: ${{ steps.check_dist.outputs.should_publish == 'true' }} - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@v1.14.0 with: packages-dir: dist/ skip-existing: true @@ -416,6 +485,8 @@ jobs: name: Generate Conda Recipe runs-on: ubuntu-latest needs: [tag, pypi] + permissions: + contents: read # Generates a recipe and uploads it as a workflow artifact only outputs: should_generate: ${{ steps.check_conda.outputs.should_generate }} @@ -455,7 +526,21 @@ jobs: mkdir -p /tmp/conda-recipe cd /tmp/conda-recipe - grayskull pypi "$PACKAGE_NAME" --strict-conda-forge + # PyPI metadata for a just-published release — especially the first + # release of a new package — can lag the upload by several minutes, + # so retry before giving up. + MAX_ATTEMPTS=5 + for attempt in $(seq 1 "$MAX_ATTEMPTS"); do + if grayskull pypi "$PACKAGE_NAME" --strict-conda-forge; then + break + fi + if [[ "$attempt" -eq "$MAX_ATTEMPTS" ]]; then + echo "::error::grayskull failed after $MAX_ATTEMPTS attempts — PyPI metadata for $PACKAGE_NAME may not be available yet" + exit 1 + fi + echo "grayskull attempt $attempt/$MAX_ATTEMPTS failed — waiting 60s for PyPI metadata to propagate" + sleep 60 + done RECIPE_PATH=$(find . -type f -path "*/meta.yaml" | head -n 1) if [[ -z "$RECIPE_PATH" ]]; then @@ -478,6 +563,9 @@ jobs: runs-on: ubuntu-latest environment: release needs: [tag, build, draft-release] + permissions: + contents: read + packages: write # Needed to push the devcontainer image to the registry outputs: should_publish: ${{ steps.check_publish.outputs.should_publish }} image_name: ${{ steps.image_name.outputs.image_name }} @@ -554,7 +642,7 @@ jobs: - name: Build and Publish Devcontainer Image if: steps.check_publish.outputs.should_publish == 'true' - uses: devcontainers/ci@v0.3 + uses: devcontainers/ci@v0.3.1900000450 with: configFile: .devcontainer/devcontainer.json push: always @@ -566,6 +654,8 @@ jobs: runs-on: ubuntu-latest needs: [tag, pypi, conda, devcontainer] if: needs.pypi.result == 'success' || needs.conda.result == 'success' || needs.devcontainer.result == 'success' + permissions: + contents: write # Needed to undraft/publish the GitHub release steps: - name: Checkout Code uses: actions/checkout@v6.1.0 diff --git a/.github/workflows/rhiza_scorecard.yml b/.github/workflows/rhiza_scorecard.yml new file mode 100644 index 0000000..b0a0d48 --- /dev/null +++ b/.github/workflows/rhiza_scorecard.yml @@ -0,0 +1,45 @@ +# This file is part of the jebel-quant/rhiza repository +# (https://github.com/jebel-quant/rhiza). +# +# Workflow: OSSF Scorecard +# +# Purpose: Run the OpenSSF Scorecard supply-chain security analysis and upload +# the results to GitHub code scanning. On public repositories the +# results are also published to the OpenSSF REST API, which powers +# the README badge and lets adopters verify the score independently. +# Set the SCORECARD_ENABLED repository variable to 'true' to +# force-enable on private repos, 'false' to disable, or leave unset +# for auto-detect (public repositories only). +# +# Thin stub: the analysis logic and its enablement/visibility gate +# live in the reusable workflow in jebel-quant/rhiza; this file only +# wires up the triggers and grants the token scopes Scorecard needs. +# +# Trigger: Weekly schedule, pushes to main, branch-protection changes, and +# manual dispatch. + +name: "(RHIZA) SCORECARD" + +on: + # Re-evaluate when branch protection rules change (Scorecard's + # Branch-Protection check reads them). + branch_protection_rule: + schedule: + - cron: '34 2 * * 2' + push: + branches: [ "main", "master" ] + workflow_dispatch: + +# Least privilege by default; the called workflow grants its job only what +# Scorecard needs (see the job-level permissions below). +permissions: read-all + +jobs: + scorecard: + uses: jebel-quant/rhiza/.github/workflows/rhiza_scorecard.yml@v1.3.3 + secrets: inherit + permissions: + security-events: write # Upload the SARIF results to code scanning + id-token: write # Publish results to the OpenSSF REST API (badge) + contents: read + actions: read diff --git a/.github/workflows/rhiza_weekly.yml b/.github/workflows/rhiza_weekly.yml index 7b03886..dcfe5ab 100644 --- a/.github/workflows/rhiza_weekly.yml +++ b/.github/workflows/rhiza_weekly.yml @@ -28,5 +28,5 @@ on: jobs: weekly: - uses: jebel-quant/rhiza/.github/workflows/rhiza_weekly.yml@v0.19.9 + uses: jebel-quant/rhiza/.github/workflows/rhiza_weekly.yml@v1.3.3 secrets: inherit diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 56689c7..07c6fad 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,12 @@ # This file is part of the jebel-quant/rhiza repository # (https://github.com/jebel-quant/rhiza). # +# Pin node so pre-commit provisions a compatible runtime instead of using the +# system node. Some npm-based hooks (markdownlint-cli) pull transitive deps that +# reject odd-numbered current node releases (e.g. v25), failing on EBADENGINE. +default_language_version: + node: "24.12.0" + repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 @@ -25,7 +31,7 @@ repos: pass_filenames: false - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.15.14' + rev: 'v0.16.2' hooks: - id: ruff args: [ --fix, --exit-non-zero-on-fix, --unsafe-fixes ] @@ -34,13 +40,13 @@ repos: - id: ruff-format - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.48.0 + rev: v0.49.0 hooks: - id: markdownlint args: ["--disable", "MD013"] - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.37.2 + rev: 0.38.0 hooks: - id: check-renovate args: [ "--verbose" ] @@ -62,10 +68,16 @@ repos: rev: 1.9.4 hooks: - id: bandit - args: ["--ini", ".bandit", "--exclude", ".venv,tests,.rhiza/tests,.git,.pytest_cache"] + # Scope deliberately lives in .bandit, not here — see that file (#1493). + args: ["--ini", ".bandit"] + + - repo: https://github.com/betterleaks/betterleaks + rev: v1.7.4 + hooks: + - id: betterleaks - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.11.16 + rev: 0.12.3 hooks: - id: uv-lock @@ -77,7 +89,7 @@ repos: files: ^src/ - repo: https://github.com/Jebel-Quant/rhiza-hooks - rev: v0.4.0 # Use the latest release + rev: v1.2.0 # Use the latest release hooks: # Migrated from rhiza - id: check-rhiza-workflow-names @@ -86,4 +98,14 @@ repos: - id: check-rhiza-config - id: check-makefile-targets - id: check-python-version-consistency + # check-bumpversion-config asserts that bump-my-version can actually discover the + # project's version config (issue #1453) — for this layer, the [tool.bumpversion] + # table in pyproject.toml, since python-core deliberately ships no .bumpversion.toml. + # It ships as of v1.1.0; it stays off because the .rhiza/tests/test_pyproject.py this + # layer also ships enforces the same invariant one gate later. + # - id: check-bumpversion-config + # check-template-bundles validates .rhiza/template.yml against the template repo's + # remote bundle list, so it fires only when that file is staged — and unlike in the + # mother repo (which has no template.yml) it is live in a synced project. Disabled in + # #660 (rhiza-hooks 0.3.0) with no reason recorded. # - id: check-template-bundles diff --git a/.rhiza/template.lock b/.rhiza/template.lock index fb2a01f..e0d6dd9 100644 --- a/.rhiza/template.lock +++ b/.rhiza/template.lock @@ -1,81 +1,68 @@ -sha: ae79c6f0729b21239655abe390269be9171f907d +sha: ac4d27b015edf70b47f0454b8251ac618f9d2bda repo: jebel-quant/rhiza host: github -ref: v0.18.8 +ref: v1.3.3 include: [] exclude: [] templates: [] +profiles: +- github-project files: - .bandit - .editorconfig +- .github/CONFIG.md +- .github/DISCUSSION_TEMPLATE/help-wanted.yml +- .github/DISCUSSION_TEMPLATE/ideas.yml - .github/DISCUSSION_TEMPLATE/q-and-a.yml - .github/ISSUE_TEMPLATE/bug_report.yml - .github/ISSUE_TEMPLATE/feature_request.yml - .github/dependabot.yml - .github/pull_request_template.md - .github/release.yml +- .github/rulesets/main-branch-protection.json +- .github/rulesets/tag-protection.json - .github/secret_scanning.yml - .github/workflows/rhiza_benchmark.yml - .github/workflows/rhiza_book.yml - .github/workflows/rhiza_ci.yml - .github/workflows/rhiza_codeql.yml +- .github/workflows/rhiza_fuzzing.yml - .github/workflows/rhiza_marimo.yml +- .github/workflows/rhiza_mutation.yml - .github/workflows/rhiza_release.yml -- .github/workflows/rhiza_sync.yml +- .github/workflows/rhiza_scorecard.yml - .github/workflows/rhiza_weekly.yml - .gitignore - .pre-commit-config.yaml - .python-version -- .rhiza/.cfg.toml - .rhiza/.env - .rhiza/.gitignore -- .rhiza/.rhiza-version - .rhiza/assets/rhiza-logo.svg - .rhiza/completions/README.md - .rhiza/completions/rhiza-completion.bash - .rhiza/completions/rhiza-completion.zsh - .rhiza/make.d/book.mk - .rhiza/make.d/bootstrap.mk +- .rhiza/make.d/completions.mk - .rhiza/make.d/custom-env.mk - .rhiza/make.d/custom-task.mk - .rhiza/make.d/doctor.mk +- .rhiza/make.d/github.mk - .rhiza/make.d/marimo.mk +- .rhiza/make.d/python.mk - .rhiza/make.d/quality.mk -- .rhiza/make.d/releasing.mk - .rhiza/make.d/test.mk -- .rhiza/requirements/README.md -- .rhiza/requirements/docs.txt -- .rhiza/requirements/marimo.txt -- .rhiza/requirements/tests.txt -- .rhiza/requirements/tools.txt - .rhiza/rhiza.mk - .rhiza/semgrep.yml - .rhiza/tests/README.md -- .rhiza/tests/api/conftest.py -- .rhiza/tests/api/test_github_targets.py -- .rhiza/tests/api/test_make_variable_overrides.py -- .rhiza/tests/api/test_makefile_api.py -- .rhiza/tests/api/test_makefile_targets.py - .rhiza/tests/conftest.py -- .rhiza/tests/integration/test_book_targets.py -- .rhiza/tests/integration/test_docs_targets.py -- .rhiza/tests/integration/test_test_mk.py -- .rhiza/tests/integration/test_virtual_env_unexport.py -- .rhiza/tests/shell/test_scripts.sh -- .rhiza/tests/stress/README.md -- .rhiza/tests/stress/__init__.py -- .rhiza/tests/stress/conftest.py -- .rhiza/tests/structure/test_project_layout.py -- .rhiza/tests/structure/test_pyproject.py -- .rhiza/tests/structure/test_requirements.py -- .rhiza/tests/sync/conftest.py -- .rhiza/tests/sync/test_docstrings.py -- .rhiza/tests/sync/test_readme_validation.py -- .rhiza/tests/test_utils.py -- .rhiza/tests/utils/test_git_repo_fixture.py -- .rhiza/utils/pip_audit_policy.py -- .rhiza/utils/suppression_audit.py +- .rhiza/tests/test_docstrings.py +- .rhiza/tests/test_pyproject.py +- .rhiza/tests/test_readme.py +- .rhiza/tests/test_readme_validation.py +- .rhiza/tests/test_release_tags.py - Makefile +- cliff.toml - docs/assets/rhiza-logo.svg - docs/development/MARIMO.md - docs/development/TESTS.md @@ -83,7 +70,6 @@ files: - docs/mkdocs-base.yml - pytest.ini - ruff.toml -profiles: -- github-project -synced_at: '2026-06-13T10:52:58Z' +- tests/test_rhiza_packaging.py +synced_at: '2026-08-17T18:34:07Z' strategy: merge diff --git a/docs/development/TESTS.md b/docs/development/TESTS.md index f77b134..b31de67 100644 --- a/docs/development/TESTS.md +++ b/docs/development/TESTS.md @@ -72,6 +72,15 @@ uv run pytest tests/property/ -v --hypothesis-max-examples=1000 uv run pytest tests/property/ -v --hypothesis-verbosity=verbose ``` +### Opting in to live DEBUG logs + +By default, the template disables live pytest CLI logging (`log_cli = false`) to keep normal test output concise. +When you need detailed live logs for debugging, enable them per-run: + +```bash +uv run pytest -o log_cli=true --log-cli-level=DEBUG +``` + ### Example Tests The following property-based tests are included as examples: @@ -193,6 +202,7 @@ Example: ```python from hypothesis import given, strategies as st, example + @given(version=st.from_regex(r"^\d+\.\d+\.\d+$", fullmatch=True)) @example(version="0.0.0") # Test specific edge case def test_version_parsing(version): @@ -256,7 +266,7 @@ pytest-benchmark>=5.2.3 pygal>=3.1.0 ``` -These are automatically installed when running `make install` or by installing from `.rhiza/requirements/tests.txt`. +These are provisioned on the fly by the relevant `make` targets via `uv run --with …` (e.g. `make test`, `make benchmark`), so no separate install step is required. ## Troubleshooting diff --git a/ruff.toml b/ruff.toml index 4563178..1678e75 100644 --- a/ruff.toml +++ b/ruff.toml @@ -3,8 +3,7 @@ # # Maximum line length for the entire project line-length = 120 -# Target Python version -target-version = "py311" +# Target Python version is inferred from project.requires-python. # Exclude directories with Jinja template variables in their names exclude = ["**/[{][{]*/", "**/*[}][}]*/"] @@ -67,22 +66,44 @@ select = [ extend-select = [ "D105", # pydocstyle - Require docstrings for magic methods "D107", # pydocstyle - Require docstrings for __init__ + "A", # flake8-builtins - Don't shadow Python builtins + "ANN001", # flake8-annotations - Require function argument annotations + "ANN2", # flake8-annotations - Require function return annotations + "ARG", # flake8-unused-arguments - Unused arguments (tests exempt below: pytest fixtures) "B", # flake8-bugbear - Find likely bugs and design problems + "BLE", # flake8-blind-except - No bare `except Exception` swallowing "C4", # flake8-comprehensions - Better list/set/dict comprehensions + "PIE", # flake8-pie - Miscellaneous lints (duplicate class fields, useless spread, ...) "SIM", # flake8-simplify - Simplify code "PT", # flake8-pytest-style - Check pytest best practices "RUF", # Ruff-specific rules "S", # flake8-bandit - Find security issues - #"ERA", # eradicate - Find commented out code - #"T10", # flake8-debugger - Check for debugger imports and calls "TRY", # flake8-try-except-raise - Try/except/raise checks "ICN", # flake8-import-conventions - Import convention enforcement - #"PIE", # flake8-pie - Miscellaneous rules - #"PL", # Pylint rules ] +# Deliberately NOT enabled — exclusions are decisions, not omissions. +# One-line rationale per family; revisit when the rationale stops holding: +# ERA: templates, notebooks, and shipped configs legitimately carry commented-out example code +# T10: stray breakpoints/pdb imports would fail local pre-commit mid-debugging; review catches leftovers +# PL: Pylint family is large and opinionated (magic values, arg counts) — high noise for a template repo +# FBT: boolean positional flags (e.g. dry_run=True) are idiomatic in our test/make drivers +# COM: trailing-comma layout is owned by `ruff format` +# ISC: implicit string concatenation is handled/conflicted by `ruff format` +# Q: quote style is owned by `ruff format` (double quotes, configured below) +# RET: return-statement micro-style; SIM already covers the valuable simplifications +# EM: literal exception messages are fine at our scale; TRY covers exception-flow issues +# DTZ: no runtime code handling datetimes ships from this repo +# SLF: private-member access is needed in tests; too coarse to enable repo-wide +# TCH/TID: no typing-only import cycles or import-tidiness issues at this size +# RSE: raise micro-style; TRY covers the error-prone patterns +# NPY/PD: no NumPy/pandas runtime code in this repository +# YTT: Python 2020 sys.version checks are irrelevant on py311+ +# PGH: its eval/blanket-ignore checks overlap with the S and RUF rules already enabled + # Resolve incompatible pydocstyle rules: prefer D211 and D212 over D203 and D213 ignore = [ + "ANN401", # dynamically typed *args/**kwargs in framework hooks are intentional "D203", # one-blank-line-before-class (conflicts with D211) "D213", # multi-line-summary-second-line (conflicts with D212) ] @@ -101,21 +122,24 @@ line-ending = "auto" # File-specific rule exceptions [lint.per-file-ignores] -# Test files - allow assert statements and subprocess calls for testing +# All test code, wherever it lives (project suite under tests/, smoke tests under +# .rhiza/tests/, and their bundle copies under bundles/). This glob subsumes the +# project suite; tests/**/*.py below adds project-suite-only allowances on top. "**/tests/**/*.py" = [ - "S101", # Allow assert statements in tests - "S603", # Allow subprocess calls without shell=False check - "S607", # Allow starting processes with partial paths in tests - "PLW1510", # Allow subprocess without explicit check parameter + "ANN", # tests prioritize readability and fixtures over strict annotation coverage + "S101", # assert is the test idiom + "S603", # tests drive git/make/uv via subprocess with fixed argument lists + "S607", # partial executable paths (git, make) are intentional in test drivers + "ARG", # pytest fixtures are requested by name for their side effects, not always read ] +# Project test suite only — allowances the synced .rhiza/tests should not inherit "tests/**/*.py" = [ - "ERA001", # Allow commented out code in project tests - "PLR2004", # Allow magic values in project tests - "RUF002", # Allow ambiguous unicode in project tests - "RUF012", # Allow mutable class attributes in project tests + "RUF002", # docstrings quote prose with typographic unicode (e.g. en dash) + "RUF012", # pytest class attributes are conventionally bare mutables, not ClassVar ] # Marimo notebooks - allow flexible coding patterns for interactive exploration "**/notebooks/*.py" = [ + "ANN", # notebooks prioritize interactive readability over strict annotation coverage "D100", # No module docstring - marimo requires `import marimo` as the first statement "N803", # Allow non-lowercase variable names in notebooks "S101", # Allow assert statements in notebooks @@ -124,8 +148,3 @@ line-ending = "auto" "RUF001", # Allow ambiguous unicode in notebooks "RUF002", # Allow ambiguous unicode in notebooks ] -# Internal utility scripts - specific exceptions for internal tooling -".rhiza/utils/*.py" = [ - "PLW2901", # Allow loop variable overwriting in utility scripts - "TRY003", # Allow long exception messages in utility scripts -] From 0ce10ca55959c7f47c116e8afb31088131a92c8f Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Tue, 18 Aug 2026 07:27:35 +0400 Subject: [PATCH 3/3] chore: drop the last files rhiza v1.3.3 no longer ships .github/workflows/rhiza_sync.yml, .rhiza/.cfg.toml, .rhiza/.rhiza-version and .rhiza/utils/. The other paths v1.3.3 dropped go with the branches that make them obsolete: .rhiza/requirements and make.d/releasing.mk in #93, the old .rhiza/tests tree in #92. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/rhiza_sync.yml | 43 ---- .rhiza/.cfg.toml | 34 --- .rhiza/.rhiza-version | 1 - .rhiza/utils/pip_audit_policy.py | 67 ------ .rhiza/utils/suppression_audit.py | 369 ------------------------------ 5 files changed, 514 deletions(-) delete mode 100644 .github/workflows/rhiza_sync.yml delete mode 100644 .rhiza/.cfg.toml delete mode 100644 .rhiza/.rhiza-version delete mode 100644 .rhiza/utils/pip_audit_policy.py delete mode 100644 .rhiza/utils/suppression_audit.py diff --git a/.github/workflows/rhiza_sync.yml b/.github/workflows/rhiza_sync.yml deleted file mode 100644 index 45d0bae..0000000 --- a/.github/workflows/rhiza_sync.yml +++ /dev/null @@ -1,43 +0,0 @@ -# This file is part of the jebel-quant/rhiza repository -# (https://github.com/jebel-quant/rhiza). -# -# Workflow: Sync -# -# Purpose: Synchronizes the repository with its upstream rhiza template. -# On Renovate/rhiza branch push: auto-commits synced files directly -# to the branch. On schedule/dispatch: opens a pull request. -# -# IMPORTANT: A PAT with 'workflow' scope (PAT_TOKEN) is required when workflow -# files are modified. See .github/CONFIG.md for setup instructions. -# -# Trigger: On Renovate/rhiza branch push, weekly schedule, and manual dispatch. - -name: "(RHIZA) SYNC" - -permissions: - contents: write - pull-requests: write - -on: - push: - branches: - - 'renovate/jebel-quant-rhiza-**' - - 'rhiza/**' - paths: - - '.rhiza/template.yml' - schedule: - - cron: '0 0 * * 1' # Weekly on Monday - workflow_dispatch: - inputs: - create-pr: - description: "Create a pull request" - type: boolean - default: true - -jobs: - sync: - uses: jebel-quant/rhiza/.github/workflows/rhiza_sync.yml@v0.19.9 - with: - direct: ${{ github.event_name == 'push' }} - create-pr: ${{ github.event_name != 'push' && (github.event_name == 'schedule' || inputs.create-pr == true) }} - secrets: inherit diff --git a/.rhiza/.cfg.toml b/.rhiza/.cfg.toml deleted file mode 100644 index c96b1dd..0000000 --- a/.rhiza/.cfg.toml +++ /dev/null @@ -1,34 +0,0 @@ -[tool.bumpversion] -parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(?:[-]?(?P[a-z]+)[\\.]?(?P\\d+))?(?:\\+build\\.(?P\\d+))?" -serialize = ["{major}.{minor}.{patch}-{release}.{pre_n}+build.{build_n}", "{major}.{minor}.{patch}+build.{build_n}", "{major}.{minor}.{patch}-{release}.{pre_n}", "{major}.{minor}.{patch}"] -search = "{current_version}" -replace = "{new_version}" -regex = false -ignore_missing_version = false -ignore_missing_files = false -tag = true -sign_tags = false -tag_name = "v{new_version}" -tag_message = "Bump version: {current_version} → {new_version}" -allow_dirty = false -commit = true -message = "Chore: bump version {current_version} → {new_version}" -commit_args = "" -pre_commit_hooks = ["uv sync", "git add uv.lock"] # Ensure uv.lock is updated - -[tool.bumpversion.parts.release] -optional_value = "prod" -values = [ - "dev", - "alpha", - "a", # PEP 440 short form for alpha - "beta", - "b", # PEP 440 short form for beta - "rc", - "prod" -] - -[[tool.bumpversion.files]] -filename = "pyproject.toml" -search = 'version = "{current_version}"' -replace = 'version = "{new_version}"' diff --git a/.rhiza/.rhiza-version b/.rhiza/.rhiza-version deleted file mode 100644 index 2a0970c..0000000 --- a/.rhiza/.rhiza-version +++ /dev/null @@ -1 +0,0 @@ -0.16.1 diff --git a/.rhiza/utils/pip_audit_policy.py b/.rhiza/utils/pip_audit_policy.py deleted file mode 100644 index 4a5db0c..0000000 --- a/.rhiza/utils/pip_audit_policy.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Run pip-audit with a tiered vulnerability policy. - -Fails the build for vulnerabilities in runtime dependencies. -Warns (without failing) for tooling packages: pip, setuptools, wheel, distribute. -Any extra arguments are forwarded to pip-audit (e.g. ``--ignore-vuln CVE-XXXX-YYYY``). -""" - -from __future__ import annotations - -import json -import shutil -import subprocess # nosec B404 -import sys - -_RESET = "\033[0m" -_RED = "\033[31m" -_YELLOW = "\033[33m" -_GREEN = "\033[32m" - -# Packages treated as build tooling — CVEs warn but do not fail CI. -_TOOLING: frozenset[str] = frozenset({"pip", "setuptools", "wheel", "distribute"}) - - -def _vuln_ids(vuln: dict) -> str: # type: ignore[type-arg] - """Return a human-readable string of all IDs for a vulnerability entry.""" - ids = [vuln["id"]] + [a for a in vuln.get("aliases", []) if a != vuln["id"]] - return ", ".join(ids) - - -def main() -> int: - """Run pip-audit and apply tiered vulnerability policy.""" - uvx = shutil.which("uvx") or "uvx" - cmd = [uvx, "pip-audit", "--format", "json", *sys.argv[1:]] - proc = subprocess.run(cmd, capture_output=True, text=True) # noqa: S603 # nosec B603 - - if proc.returncode == 0: - print(f"{_GREEN}[OK] pip-audit: no vulnerabilities found{_RESET}") - return 0 - - try: - data = json.loads(proc.stdout) - except json.JSONDecodeError: - sys.stdout.write(proc.stdout) - sys.stderr.write(proc.stderr) - return proc.returncode - - deps = data.get("dependencies", []) - tooling_vulns = [d for d in deps if d.get("vulns") and d["name"].lower() in _TOOLING] - runtime_vulns = [d for d in deps if d.get("vulns") and d["name"].lower() not in _TOOLING] - - for dep in tooling_vulns: - for v in dep["vulns"]: - print( - f"{_YELLOW}[WARN] {dep['name']}=={dep['version']}: {_vuln_ids(v)} (tooling — not failing build){_RESET}" - ) - - if not runtime_vulns: - return 0 - - for dep in runtime_vulns: - for v in dep["vulns"]: - print(f"{_RED}[FAIL] {dep['name']}=={dep['version']}: {_vuln_ids(v)}{_RESET}") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.rhiza/utils/suppression_audit.py b/.rhiza/utils/suppression_audit.py deleted file mode 100644 index 069d2e7..0000000 --- a/.rhiza/utils/suppression_audit.py +++ /dev/null @@ -1,369 +0,0 @@ -"""Suppression audit: scan codebase for inline suppressions of security, coverage, docs, and linting. - -Detects and reports on inline suppression comments such as: -- ``# noqa`` / ``# noqa: CODE`` (ruff/flake8 linting suppressions) -- ``# nosec`` / ``# nosec: CODE`` (bandit security suppressions) -- ``# type: ignore`` / ``# type: ignore[CODE]`` (mypy/pyright type-checking suppressions) -- ``# pragma: no cover`` (coverage suppressions) -- ``# noinspection CODE`` (PyCharm/IDE suppressions) - -Outputs a detailed per-file report, an ASCII histogram, and a letter grade. -""" - -from __future__ import annotations - -import argparse -import io -import json -import re -import shutil -import subprocess # nosec B404 -import sys -import tokenize -from collections import Counter -from dataclasses import dataclass, field -from pathlib import Path - -# --------------------------------------------------------------------------- -# Suppression patterns -# --------------------------------------------------------------------------- - -# Each entry: (kind_label, compiled_regex). -# The first capture group (if any) captures the comma-separated rule codes. -SUPPRESSION_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ - ( - "noqa", - re.compile(r"#\s*noqa(?:\s*:\s*([A-Z0-9]+(?:\s*,\s*[A-Z0-9]+)*))?", re.IGNORECASE), - ), - ( - "nosec", - re.compile(r"#\s*nosec(?:\s*:?\s*([A-Z0-9]+(?:\s*,\s*[A-Z0-9]+)*))?", re.IGNORECASE), - ), - ( - "type:ignore", - re.compile(r"#\s*type\s*:\s*ignore(?:\[([^\]]+)\])?", re.IGNORECASE), - ), - ( - "no cover", - re.compile(r"#\s*pragma\s*:\s*no\s+cover", re.IGNORECASE), - ), - ( - "noinspection", - re.compile(r"#\s*noinspection\s+(\w+)", re.IGNORECASE), - ), -] - -# Directories to skip during the scan -_SKIP_DIRS = {".venv", ".git", "node_modules", ".tox", "build", "dist", "__pycache__", "tests"} - - -# --------------------------------------------------------------------------- -# Data model -# --------------------------------------------------------------------------- - - -@dataclass -class Suppression: - """Represents a single suppression comment found in the codebase.""" - - file: str - line_no: int - kind: str - codes: list[str] = field(default_factory=list) - raw: str = "" - - -# --------------------------------------------------------------------------- -# Scanning helpers -# --------------------------------------------------------------------------- - - -def _should_skip(path: Path) -> bool: - """Return True if any path component is in the skip-list.""" - return bool(_SKIP_DIRS.intersection(path.parts)) - - -def _is_rhiza_repo(root: Path) -> bool: - """Return True if *root* is the rhiza framework repo itself. - - Consumer repos have a ``.rhiza/template.yml`` file that records the upstream - rhiza repository reference. The rhiza repo itself never has this file — its - absence is the reliable signal that we are running inside the framework repo. - """ - return not (root / ".rhiza" / "template.yml").exists() - - -def scan_file(path: Path) -> list[Suppression]: - """Scan a single Python file and return all suppressions found. - - Uses Python's ``tokenize`` module so that only actual comment tokens are - inspected — patterns that appear inside string literals or docstrings are - correctly ignored. - """ - suppressions: list[Suppression] = [] - try: - source = path.read_text(encoding="utf-8", errors="replace") - except OSError: - return suppressions - - try: - tokens = tokenize.generate_tokens(io.StringIO(source).readline) - for tok_type, tok_string, tok_start, _tok_end, _line in tokens: - if tok_type != tokenize.COMMENT: - continue - line_no = tok_start[0] - for kind, pattern in SUPPRESSION_PATTERNS: - match = pattern.search(tok_string) - if match: - codes_raw = match.group(1) if match.lastindex and match.group(1) else "" - codes = [c.strip() for c in codes_raw.split(",") if c.strip()] if codes_raw else [] - suppressions.append( - Suppression( - file=str(path), - line_no=line_no, - kind=kind, - codes=codes, - raw=tok_string.strip(), - ) - ) - break # count each comment line once - except tokenize.TokenError: - pass # skip files with tokenization errors (e.g. incomplete source) - - return suppressions - - -def count_non_empty_lines(path: Path) -> int: - """Count non-empty lines in a file.""" - try: - return sum(1 for line in path.read_text(encoding="utf-8", errors="replace").splitlines() if line.strip()) - except OSError: - return 0 - - -# --------------------------------------------------------------------------- -# Grading -# --------------------------------------------------------------------------- - -# Grade thresholds: suppressions per 100 lines of code -_GRADE_THRESHOLDS: list[tuple[float, str]] = [ - (0.0, "A+"), - (0.5, "A"), - (1.0, "B"), - (2.0, "C"), - (3.0, "D"), -] - - -def compute_grade(density: float) -> str: - """Return a letter grade based on suppression density (count per 100 lines).""" - grade = "F" - for threshold, letter in _GRADE_THRESHOLDS: - if density <= threshold: - grade = letter - break - return grade - - -# --------------------------------------------------------------------------- -# Rendering helpers -# --------------------------------------------------------------------------- - -_BAR_WIDTH = 24 - - -def _bar(count: int, max_count: int) -> str: - """Render a fixed-width ASCII progress bar.""" - if max_count == 0: - return "░" * _BAR_WIDTH - filled = round(count / max_count * _BAR_WIDTH) - return "█" * filled + "░" * (_BAR_WIDTH - filled) - - -_GRADE_COLOURS = { - "A+": "\033[92m", # bright green - "A": "\033[32m", # green - "B": "\033[32m", # green - "C": "\033[33m", # yellow - "D": "\033[33m", # yellow - "F": "\033[31m", # red -} -_RESET = "\033[0m" -_BOLD = "\033[1m" -_BLUE = "\033[36m" -_YELLOW = "\033[33m" -_GREEN = "\033[32m" -_RED = "\033[31m" -_CVE_RE = re.compile(r"\bCVE-\d{4}-\d+\b", re.IGNORECASE) - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - - -def _active_pip_audit_ids(extra_args: list[str]) -> set[str]: - """Return vulnerability IDs currently reported by pip-audit.""" - uvx = shutil.which("uvx") or "uvx" - cmd = [uvx, "pip-audit", "--format", "json", *extra_args] - proc = subprocess.run(cmd, capture_output=True, text=True) # noqa: S603 # nosec B603 - - if proc.returncode not in {0, 1}: - sys.stdout.write(proc.stdout) - sys.stderr.write(proc.stderr) - raise RuntimeError("pip-audit execution failed") - - try: - data = json.loads(proc.stdout or "{}") - except json.JSONDecodeError as exc: - raise RuntimeError("pip-audit did not return valid JSON") from exc - - ids: set[str] = set() - for dep in data.get("dependencies", []): - for vuln in dep.get("vulns", []): - vuln_id = vuln.get("id") - if vuln_id: - ids.add(str(vuln_id).upper()) - for alias in vuln.get("aliases", []): - ids.add(str(alias).upper()) - return ids - - -def _nosec_cves(suppressions: list[Suppression]) -> set[str]: - """Extract CVE identifiers referenced by # nosec suppressions.""" - cves: set[str] = set() - for sup in suppressions: - if sup.kind != "nosec": - continue - cves.update(match.upper() for match in _CVE_RE.findall(sup.raw)) - return cves - - -def _collect_suppressions(root: Path) -> tuple[list[Path], list[Suppression], int]: - """Collect Python files, suppressions, and non-empty line counts.""" - in_rhiza_repo = _is_rhiza_repo(root) - - def _include(p: Path) -> bool: - if _should_skip(p): - return False - # In consumer repos, skip the .rhiza/ framework directory entirely - return not (not in_rhiza_repo and ".rhiza" in p.parts) - - py_files = sorted(p for p in root.rglob("*.py") if _include(p)) - - all_suppressions: list[Suppression] = [] - total_lines = 0 - for py_file in py_files: - all_suppressions.extend(scan_file(py_file)) - total_lines += count_non_empty_lines(py_file) - - return py_files, all_suppressions, total_lines - - -def _print_report(py_files: list[Path], all_suppressions: list[Suppression], total_lines: int) -> None: - """Print the suppression audit report.""" - # ----------------------------------------------------------------------- - # Header - # ----------------------------------------------------------------------- - print() - print(f"{_BOLD}{'=' * 62}{_RESET}") - print(f"{_BOLD} Suppression Audit Report{_RESET}") - print(f"{_BOLD}{'=' * 62}{_RESET}") - print() - - # ----------------------------------------------------------------------- - # Detailed per-file report - # ----------------------------------------------------------------------- - print(f"{_BOLD}Detailed Report:{_RESET}") - if all_suppressions: - for sup in all_suppressions: - codes_str = f"[{', '.join(sup.codes)}]" if sup.codes else "" - print(f" {_YELLOW}{sup.file}{_RESET}:{_GREEN}{sup.line_no}{_RESET}: # {sup.kind}{codes_str}") - else: - print(f" {_GREEN}No inline suppressions found.{_RESET}") - print() - - # ----------------------------------------------------------------------- - # Histogram by code - # ----------------------------------------------------------------------- - print(f"{_BOLD}Histogram (by suppression code):{_RESET}") - code_counter: Counter[str] = Counter() - for sup in all_suppressions: - if sup.codes: - for code in sup.codes: - code_counter[f"{sup.kind}[{code}]"] += 1 - else: - code_counter[f"{sup.kind}"] += 1 - if code_counter: - max_code_count = max(code_counter.values()) - total_code_count = sum(code_counter.values()) - for label, count in code_counter.most_common(): - pct = count / total_code_count * 100 - print(f" {label:<20} {_BLUE}{_bar(count, max_code_count)}{_RESET} {count:>3} ({pct:.0f}%)") - else: - print(" (none)") - print() - - # ----------------------------------------------------------------------- - # Summary + Grade - # ----------------------------------------------------------------------- - density = (len(all_suppressions) / total_lines * 100) if total_lines > 0 else 0.0 - grade = compute_grade(density) - grade_colour = _GRADE_COLOURS.get(grade, _RESET) - - print(f"{_BOLD}Summary:{_RESET}") - print(f" Files scanned : {len(py_files)}") - print(f" Lines scanned : {total_lines:,}") - print(f" Suppressions : {len(all_suppressions)}") - print(f" Density : {density:.2f} per 100 lines") - print() - print(f" Grade : {grade_colour}{_BOLD}{grade}{_RESET}") - print() - - -def _check_stale_nosec_cves(suppressions: list[Suppression], pip_audit_args: list[str]) -> int: - """Validate CVE-tagged # nosec suppressions against active pip-audit findings.""" - suppressed_cves = _nosec_cves(suppressions) - if not suppressed_cves: - print(f"{_GREEN}[OK]{_RESET} No CVE-tagged # nosec suppressions found.") - return 0 - - try: - active_cves = _active_pip_audit_ids(pip_audit_args) - except RuntimeError as exc: - print(f"{_RED}[FAIL]{_RESET} {exc}") - return 2 - - stale = sorted(cve for cve in suppressed_cves if cve not in active_cves) - if stale: - print(f"{_RED}[FAIL]{_RESET} Stale # nosec CVE suppressions detected:") - for cve in stale: - print(f" - {cve}") - return 1 - - print(f"{_GREEN}[OK]{_RESET} All CVE-tagged # nosec suppressions match active pip-audit findings.") - return 0 - - -def main(argv: list[str] | None = None) -> int: - """Run the suppression audit and print a structured report.""" - parser = argparse.ArgumentParser(add_help=True) - parser.add_argument( - "--fail-stale-nosec-cve", - action="store_true", - help="Fail when # nosec comments reference CVEs that pip-audit no longer reports.", - ) - args, pip_audit_args = parser.parse_known_args(argv) - - root = Path(".") - py_files, all_suppressions, total_lines = _collect_suppressions(root) - _print_report(py_files, all_suppressions, total_lines) - - if args.fail_stale_nosec_cve: - return _check_stale_nosec_cves(all_suppressions, pip_audit_args) - - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:]))