diff --git a/.github/settings/actions-selected.json b/.github/settings/actions-selected.json new file mode 100644 index 0000000..d8d64d0 --- /dev/null +++ b/.github/settings/actions-selected.json @@ -0,0 +1,15 @@ +{ + "github_owned_allowed": true, + "verified_allowed": false, + "patterns_allowed": [ + "github/codeql-action@*", + "actions-rust-lang/setup-rust-toolchain@*", + "astral-sh/setup-uv@*", + "codecov/codecov-action@*", + "zizmorcore/zizmor-action@*", + "taiki-e/cache-cargo-install-action@*", + "swatinem/rust-cache@*", + "dependabot/fetch-metadata@*", + "acgetchell/research-repo-tools/.github/workflows/dependabot-approve.yml@*" + ] +} diff --git a/.github/settings/actions-workflow.json b/.github/settings/actions-workflow.json new file mode 100644 index 0000000..5458c59 --- /dev/null +++ b/.github/settings/actions-workflow.json @@ -0,0 +1,4 @@ +{ + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": true +} diff --git a/.github/settings/main-ruleset.json b/.github/settings/main-ruleset.json new file mode 100644 index 0000000..6946453 --- /dev/null +++ b/.github/settings/main-ruleset.json @@ -0,0 +1,78 @@ +{ + "name": "main", + "target": "branch", + "enforcement": "active", + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + } + ], + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "~DEFAULT_BRANCH" + ] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": true, + "required_status_checks": [ + { + "context": "build (macos-latest)", + "integration_id": 15368 + }, + { + "context": "build (ubuntu-latest)", + "integration_id": 15368 + }, + { + "context": "build (windows-latest)", + "integration_id": 15368 + }, + { + "context": "Clippy SARIF Analysis", + "integration_id": 15368 + }, + { + "context": "CodeRabbit", + "integration_id": 347564 + }, + { + "context": "Repository Rule SARIF Analysis", + "integration_id": 15368 + } + ] + } + } + ] +} diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 8c95e15..bb64128 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -1,66 +1,48 @@ -name: Dependabot review and auto-merge +name: Dependabot approval and auto-merge -on: - pull_request: +# Trusted base-branch policy only; the reusable job never checks out or runs PR code. +on: # zizmor: ignore[dangerous-triggers] + pull_request_target: # nosemgrep: la-stack.github-actions.no-pull-request-target types: - opened - reopened - ready_for_review - synchronize + branches: + - main concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + group: dependabot-auto-merge-${{ github.event.pull_request.number }} cancel-in-progress: true -permissions: - contents: write - pull-requests: write +permissions: {} jobs: - review-and-enable-auto-merge: - if: >- - github.event.pull_request.user.login == 'dependabot[bot]' && - github.repository == 'acgetchell/la-stack' - runs-on: ubuntu-latest - steps: - - name: Request CodeRabbit review - env: - # Dependabot-triggered workflows receive Dependabot secrets. - GH_TOKEN: ${{ secrets.CODERABBIT_REVIEW_TOKEN }} - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PR_NUMBER: ${{ github.event.pull_request.number }} - REPOSITORY: ${{ github.repository }} - run: | - if [[ -z "$GH_TOKEN" ]]; then - echo "::error::CODERABBIT_REVIEW_TOKEN is not configured." - exit 1 - fi - - token_owner="$(gh api user --jq '.login')" - if [[ "$token_owner" != "acgetchell" ]]; then - echo "::error::CODERABBIT_REVIEW_TOKEN must belong to acgetchell." - exit 1 - fi - - marker="" - comments="$( - gh api --paginate \ - "repos/${REPOSITORY}/issues/${PR_NUMBER}/comments" \ - --jq '.[] | select(.user.login == "acgetchell") | .body' - )" - if grep -Fxq -- "$marker" <<< "$comments"; then - echo "CodeRabbit review already requested for ${PR_HEAD_SHA}." - exit 0 - fi - body="$(printf '@coderabbitai review\n\n%s' "$marker")" - gh api \ - --method POST \ - "repos/${REPOSITORY}/issues/${PR_NUMBER}/comments" \ - --raw-field body="$body" - - - name: Enable auto-merge - env: - GH_TOKEN: ${{ github.token }} - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PR_URL: ${{ github.event.pull_request.html_url }} - run: gh pr merge --auto --squash --match-head-commit "$PR_HEAD_SHA" "$PR_URL" + approve-and-enable-auto-merge: + permissions: + contents: write + pull-requests: write + uses: acgetchell/research-repo-tools/.github/workflows/dependabot-approve.yml@cbb2ea6dee8866b3f0547bca935aef48fdd71707 # reviewed 2026-10-01 + with: + repository: acgetchell/la-stack + policy: >- + {"cargo": { + "files": ["Cargo.toml", "Cargo.lock", "benches/comparison/Cargo.toml"] + }, "uv": { + "files": ["pyproject.toml", "uv.lock"] + }, "github_actions": { + "files": [ + ".github/actions/prepare-release-benchmarks/action.yml", + ".github/actions/setup-tools/action.yml", + ".github/workflows/audit.yml", + ".github/workflows/benchmarks.yml", + ".github/workflows/ci.yml", + ".github/workflows/codecov.yml", + ".github/workflows/codeql.yml", + ".github/workflows/dependabot-auto-merge.yml", + ".github/workflows/release-benchmarks.yml", + ".github/workflows/rust-clippy.yml", + ".github/workflows/semgrep-sarif.yml", + ".github/workflows/zizmor.yml" + ] + }} diff --git a/.github/workflows/semgrep-sarif.yml b/.github/workflows/semgrep-sarif.yml index a678fe3..5106948 100644 --- a/.github/workflows/semgrep-sarif.yml +++ b/.github/workflows/semgrep-sarif.yml @@ -48,9 +48,12 @@ jobs: --timeout 30 \ --config semgrep.yaml \ --sarif \ - --output semgrep-results.sarif \ + --output semgrep-raw.sarif \ . status=$? + set -e + # GitHub ignores SARIF suppression metadata; honor reviewed inline exceptions. + jq '.runs[].results |= map(select(any(.suppressions[]?; .kind == "inSource") | not))' semgrep-raw.sarif > semgrep-results.sarif echo "exit_code=$status" >> "$GITHUB_OUTPUT" exit 0 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index c48899b..882b45f 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -21,6 +21,7 @@ jobs: analyze: name: GitHub Actions Security Analysis runs-on: ubuntu-latest + timeout-minutes: 30 permissions: actions: read contents: read @@ -35,24 +36,26 @@ jobs: - name: Set up declared tools uses: ./.github/actions/setup-tools # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... - - name: Run declared zizmor with SARIF output + - name: Fail on workflow findings + id: audit env: - GH_TOKEN: ${{ github.token }} - run: | - scan_status=0 - uv run --locked --no-sync --no-python-downloads research-repo-tools \ - toolchain run -- zizmor --persona regular --format sarif .github > zizmor-results.sarif || scan_status=$? - if [[ ! -s zizmor-results.sarif ]] || ! jq -e '.version == "2.1.0" and (.runs | type == "array")' zizmor-results.sarif > /dev/null; then - rm -f zizmor-results.sarif - fi - exit "$scan_status" + ZIZMOR_GITHUB_TOKEN: ${{ github.token }} + run: just zizmor --require-online + + - name: Produce SARIF even when the finding gate fails + id: sarif + if: ${{ !cancelled() && steps.audit.outcome != 'skipped' }} + env: + ZIZMOR_GITHUB_TOKEN: ${{ github.token }} + run: just zizmor --require-online --format sarif > "$RUNNER_TEMP/zizmor.sarif" - name: Upload SARIF results if: >- - always() && hashFiles('zizmor-results.sarif') != '' && - (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + !cancelled() && steps.sarif.outcome == 'success' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && + github.actor != 'dependabot[bot]' uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: - sarif_file: zizmor-results.sarif + sarif_file: ${{ runner.temp }}/zizmor.sarif category: zizmor wait-for-processing: true diff --git a/AGENTS.md b/AGENTS.md index 9bf1f37..34e0766 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -191,6 +191,8 @@ Favor the invariant over a convenient edit or faster implementation. - Read [Documentation guidance](docs/dev/docs.md) for README inclusion in rustdoc, guide placement, feature-gated doctests, link destinations, scientific notation, and generated-file rules. + The [command policy](docs/dev/docs.md#command-discovery-and-placement) owns + recipe naming, generated help, and README/contributor command placement. ## Validation diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 41590be..1e39469 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,9 @@ just ci # run the comprehensive local CI path ``` Use `just fix` when you intentionally want formatters and automatic fixes to -change files. Run `just --list` for the full command surface. +change files. Run bare `just` for the complete generated command list, including +arguments and descriptions. The [command policy](docs/dev/docs.md#command-discovery-and-placement) +owns recipe naming and documentation placement. Setup, tool checks, updates, changelog, and opt-in CodeRabbit review use the published `research-repo-tools==0.1.7` package, locked in the `tooling` dependency group and included by `dev`. Normal setup @@ -76,17 +78,20 @@ an explicit allowlist, and kept with readable version comments for review. `just zizmor` uses the same pinned scanner and `regular` persona as the SARIF workflow. It runs online audits using `ZIZMOR_GITHUB_TOKEN`, `GH_TOKEN`, -`GITHUB_TOKEN`, or an authenticated `gh auth token`, in that order, without +or an authenticated `gh auth token`, in that order, without printing the token. Without authentication it reports an offline fallback; SHA/version-comment resolution and other online findings are then unchecked. -Use `ZIZMOR_OFFLINE=true just zizmor` to request offline audits explicitly. +Use `just zizmor --offline` for deliberate offline audits, or +`just zizmor --require-online` to require authentication, as hosted CI does. Zizmor owns remote action SHA/tag resolution; Semgrep guards managed scanner execution and cache isolation in release workflows. The local authentication -adapter and its tests remain consumer-owned security policy. +adapter and authentication regressions belong to research-repo-tools. Hosted +CI runs a blocking finding check before a separate SARIF export; native SARIF +output alone does not fail on findings. `just security` runs the shared OSV dependency audit for `uv.lock` and `Cargo.lock`, then the Gitleaks scan of reachable Git history and current tracked/nonignored -files. Run either gate separately with `just security-osv` or +files. Run either gate separately with `just audit` or `just security-secrets`. Setup installs both declared managed binaries. OSV needs network access, and Gitleaks requires a complete Git checkout. JSON/SARIF reports are retained under `target/security`, with secret findings redacted. These gates @@ -127,6 +132,21 @@ live CodeRabbit only on an explicit maintainer request. Consumer integration tests use local stubs and never contact CodeRabbit. This local workflow is separate from hosted GitHub review and Dependabot auto-merge. +### Dependabot approvals + +Hosted dependency updates use the same pinned research-repo-tools approval +workflow as markov-chain-monte-carlo. Trusted base-branch policy verifies signed +Dependabot commits, complete changed-file allowlists, and the current head +before a deduplicated `github-actions[bot]` approval. Native squash auto-merge +still requires the configured approvals, strict checks (including CodeRabbit's +status), resolved threads, and dismissal of stale approvals. + +This replaces owner-authored CodeRabbit requests and their personal token. +CodeRabbit's successful status on a skipped bot review is not an approval. +The [rollout guide](docs/dev/MANAGING_CHANGES.md#dependabot-approval-rollout) +owns GitHub settings and post-merge verification. The workflow never executes +PR code; dependency eligibility remains in `.github/dependabot.yml`. + ### Rust Toolchain Policy The `bench-compile` recipe uses Cargo's `CARGO_BUILD_WARNINGS=deny` policy for @@ -213,7 +233,7 @@ For individual edited file formats, use these focused checks and formatters: | Surface | Commands | |---------|----------| | JSON | `jq empty .json` or `just validate-json` | -| TOML | `just toml-lint`, `just toml-fmt-check`; format with `just toml-fmt` | +| TOML | `just toml-lint`, `just toml-fmt-check`; format with `just toml-fix` | | GitHub Actions | `just action-lint` | | Shell scripts | `just shell-fix`, then `just shell-check` | | YAML | `just yaml-fix`, then `just yaml-lint` | diff --git a/README.md b/README.md index 9a0a8b8..b3472d5 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.18158926.svg)](https://doi.org/10.5281/zenodo.18158926) [![Crates.io](https://badgen.net/crates/v/la-stack)](https://crates.io/crates/la-stack) [![Downloads](https://badgen.net/crates/d/la-stack)](https://crates.io/crates/la-stack) -[![License](https://badgen.net/github/license/acgetchell/la-stack)](https://github.com/acgetchell/la-stack/blob/v0.4.6/LICENSE) +[![License](https://badgen.net/github/license/acgetchell/la-stack)](https://github.com/acgetchell/la-stack/blob/main/LICENSE) [![Docs.rs](https://docs.rs/la-stack/badge.svg)](https://docs.rs/la-stack) [![CI](https://github.com/acgetchell/la-stack/actions/workflows/ci.yml/badge.svg)](https://github.com/acgetchell/la-stack/actions/workflows/ci.yml) [![rust-clippy analyze][clippy-badge]][clippy-workflow] @@ -56,8 +56,6 @@ while keeping the API intentionally small and explicit. Each independent dot product is checked once; rounding has no certified error bound, and positive definiteness or affine independence must still be established by factorization or the caller. - Benchmark square simplex and rectangular facet inputs through dimension 8 - with `cargo bench --locked --features bench --bench gram`. - `Interval` and `IntervalMatrix` for outward-rounded, proof-bearing determinant filters through D=7 - `Ldlt` for no-pivot factorization intended for exactly @@ -134,6 +132,24 @@ provide a certified solution error bound. - `exact`: exact determinant signs, determinant values, and solves over stored `f64` values or caller-supplied `BigRational` inputs +### Run the repository examples + +After the [prerequisite setup][contributor-setup], discover commands and run +the complete examples, including exact determinants and solves: + +```bash +just +just examples +``` + +To run the pivoted solve above individually: + +```bash +cargo run --locked --example solve_5x5 +``` + +[contributor-setup]: https://github.com/acgetchell/la-stack/blob/main/CONTRIBUTING.md#getting-started + ## πŸ”’ Scalar and bounded-value types The public point-value scalar model deliberately has two input domains: @@ -338,9 +354,9 @@ for the algorithms, validity boundaries, and supporting references. - βœ… No runtime dependencies by default (optional features may add deps) - βœ… `unsafe` forbidden -See [CHANGELOG.md](https://github.com/acgetchell/la-stack/blob/v0.4.6/CHANGELOG.md) +See [CHANGELOG.md](https://github.com/acgetchell/la-stack/blob/main/CHANGELOG.md) for release history and -[docs/roadmap.md](https://github.com/acgetchell/la-stack/blob/v0.4.6/docs/roadmap.md) +[docs/roadmap.md](https://github.com/acgetchell/la-stack/blob/main/docs/roadmap.md) for current release planning. ## 🚫 Anti-goals @@ -361,12 +377,13 @@ for current release planning. ## πŸ—ΊοΈ Documentation Map - [API guide][api-guide] β€” worked examples, API selection, storage, and error contracts. -- [Benchmarking](https://github.com/acgetchell/la-stack/blob/v0.4.6/docs/BENCHMARKING.md) β€” benchmark suites, comparison workflows, and measurement methodology. +- [Architecture](https://github.com/acgetchell/la-stack/blob/main/docs/code_organization.md) β€” module, feature, file, and documentation ownership. +- [Benchmarking](https://github.com/acgetchell/la-stack/blob/main/docs/BENCHMARKING.md) β€” benchmark suites, comparison workflows, and measurement methodology. - [Coverage](https://github.com/acgetchell/la-stack/blob/main/docs/MEASURING_COVERAGE.md) β€” local and CI coverage commands and report locations. - [Mathematical basis](https://github.com/acgetchell/la-stack/blob/main/docs/mathematical_basis.md) β€” algorithms, numerical guarantees, and limitations. - [Performance reports](https://github.com/acgetchell/la-stack/blob/main/docs/performance.md) β€” release-to-release measurement results and provenance. -- [Releasing](https://github.com/acgetchell/la-stack/blob/v0.4.6/docs/RELEASING.md) β€” release preparation, validation, and publication. -- [Roadmap](https://github.com/acgetchell/la-stack/blob/v0.4.6/docs/roadmap.md) β€” release planning, future directions, and non-goals. +- [Releasing](https://github.com/acgetchell/la-stack/blob/main/docs/RELEASING.md) β€” release preparation, validation, and publication. +- [Roadmap](https://github.com/acgetchell/la-stack/blob/main/docs/roadmap.md) β€” release planning, future directions, and non-goals. ## πŸ“‹ Examples @@ -421,7 +438,7 @@ README table, and provenance together. For the full per-kernel comparison methodology, algorithm citations, input construction, and release-comparison workflow details, see -[docs/BENCHMARKING.md](https://github.com/acgetchell/la-stack/blob/v0.4.6/docs/BENCHMARKING.md). +[docs/BENCHMARKING.md](https://github.com/acgetchell/la-stack/blob/main/docs/BENCHMARKING.md). For the current release-to-release performance snapshot, see [docs/performance.md](https://github.com/acgetchell/la-stack/blob/main/docs/performance.md). The exact release suite includes the already-exact rational-input groups for @@ -458,40 +475,15 @@ expectations are validated outside the timed closures. ## 🀝 Contributing -A short contributor workflow: - -Install Git, -[GitHub CLI](https://cli.github.com/), [`uv`](https://docs.astral.sh/uv/) at the -exact version declared in `pyproject.toml`, and `jq`. Set up declared Python, Rust, Cargo tools, and Just through the locked -shared package: - -```bash -uv run --locked --managed-python --only-group tooling research-repo-tools setup -just setup # install/verify dev tools + sync Python deps + build -just check # lint/validate (non-mutating) -just fix # apply auto-fixes (mutating) -just ci # lint + tests + examples + bench compile -``` - -The repository uses `cargo-nextest` for runnable Rust tests, `cargo-machete` -for unused-dependency checks, `rumdl` for Markdown, `dprint` plus `yamllint` -for YAML/CFF, `taplo` for TOML, and `typos` for spelling. Python 3.14 support -tooling is locked with `uv` and checked by Ruff, Ty, and Semgrep. GitHub Actions -references are SHA-pinned, restricted to an explicit allowlist, and kept with -readable version comments for review. - -CI runs `just ci` on Ubuntu, macOS, and Windows to keep platform coverage -aligned with the local comprehensive validation path. - -For coverage commands and report locations, see -[`docs/MEASURING_COVERAGE.md`](https://github.com/acgetchell/la-stack/blob/main/docs/MEASURING_COVERAGE.md). -For the full contributor workflow, see -[CONTRIBUTING.md](https://github.com/acgetchell/la-stack/blob/v0.4.6/CONTRIBUTING.md). +See [CONTRIBUTING.md](https://github.com/acgetchell/la-stack/blob/main/CONTRIBUTING.md) +for prerequisite setup, checks and fixes, tests, security scans, and PR preparation. +Coverage execution belongs in +[Measuring coverage](https://github.com/acgetchell/la-stack/blob/main/docs/MEASURING_COVERAGE.md). ## πŸ“š Citation If you use this library in academic work, please cite it using -[CITATION.cff](https://github.com/acgetchell/la-stack/blob/v0.4.6/CITATION.cff) +[CITATION.cff](https://github.com/acgetchell/la-stack/blob/main/CITATION.cff) (or GitHub's "Cite this repository" feature). Tagged releases are archived on Zenodo under the [all-versions concept DOI](https://doi.org/10.5281/zenodo.18158926). @@ -499,19 +491,19 @@ Zenodo under the ## πŸ”Ž References For canonical references to the algorithms used by this crate, see -[REFERENCES.md](https://github.com/acgetchell/la-stack/blob/v0.4.6/REFERENCES.md). +[REFERENCES.md](https://github.com/acgetchell/la-stack/blob/main/REFERENCES.md). ## πŸ€– AI Agents AI coding assistants should read -[AGENTS.md](https://github.com/acgetchell/la-stack/blob/v0.4.6/AGENTS.md) +[AGENTS.md](https://github.com/acgetchell/la-stack/blob/main/AGENTS.md) before proposing or applying changes. See -[CONTRIBUTING.md](https://github.com/acgetchell/la-stack/blob/v0.4.6/CONTRIBUTING.md) +[CONTRIBUTING.md](https://github.com/acgetchell/la-stack/blob/main/CONTRIBUTING.md) for the repository's AI-assisted development note. ## πŸ“œ License -BSD 3-Clause License. See [LICENSE](https://github.com/acgetchell/la-stack/blob/v0.4.6/LICENSE). +BSD 3-Clause License. See [LICENSE](https://github.com/acgetchell/la-stack/blob/main/LICENSE). [audit-badge]: https://github.com/acgetchell/la-stack/actions/workflows/audit.yml/badge.svg [audit-workflow]: https://github.com/acgetchell/la-stack/actions/workflows/audit.yml diff --git a/docs/RELEASING.md b/docs/RELEASING.md index e30f211..f72359f 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -79,8 +79,9 @@ just update-version "$TAG" The recipe requires a stable `vX.Y.Z` target that is not older than any published stable GitHub release and that has at least one earlier published stable release. It infers the previous release from GitHub and updates the Rust -and Python package metadata and lockfiles, `CITATION.cff`, README dependency and -non-artifact links, and active benchmark examples. `date-released` uses the +and Python package metadata and lockfiles, `CITATION.cff`, and README dependency +metadata. Active navigation stays on `main`; measured artifact links retain their +recorded revision. `date-released` uses the current UTC date; if the target changelog section already exists, its date is updated in the same transaction. `CITATION.cff` retains the Zenodo all-versions concept DOI. diff --git a/docs/code_organization.md b/docs/code_organization.md index 40df6f3..809c591 100644 --- a/docs/code_organization.md +++ b/docs/code_organization.md @@ -133,6 +133,15 @@ actual release selectors and preservation of scientific evidence. `scripts/tests/test_cargo_update_integration.py` exercises native dependency upgrades and coupled exclusions against a disposable local registry. +The shared package also owns process discovery, captured/live execution, exact +byte transport, CPU metadata, diagnostics, and zizmor authentication. The thin +`scripts/benchmark_process.py` adapter retains benchmark phase signatures and +consumer root selection. Generic `subprocess_utils.py` and `run_zizmor.sh` +implementations and their duplicate tests are retired; scientific schemas and +benchmark policy remain here. Hosted Dependabot approvals use the pinned shared +GitHub workflow; the [rollout guide](dev/MANAGING_CHANGES.md#dependabot-approval-rollout) +owns settings and deployment verification. + `scripts/release_baseline.py` owns release-suite inventory and complete raw Criterion validation. The release workflow packages only datasets that pass that gate; its regression and archive tests live in diff --git a/docs/dev/MANAGING_CHANGES.md b/docs/dev/MANAGING_CHANGES.md index e7718e3..3b6a9f5 100644 --- a/docs/dev/MANAGING_CHANGES.md +++ b/docs/dev/MANAGING_CHANGES.md @@ -9,6 +9,7 @@ Operational details for the Git rules in [AGENTS.md](../../AGENTS.md). - [GitHub CLI](#github-cli) - [Issue planning](#issue-planning) - [Issue dependencies](#issue-dependencies) +- [Dependabot approval rollout](#dependabot-approval-rollout) ## Git operations and branch names @@ -113,3 +114,58 @@ gh api repos/acgetchell/la-stack/issues/217/dependencies/blocked_by \ Use `-F` so the resolved numeric ID is encoded as an integer. Keep independent tasks related without inventing a blocking dependency. + +## Dependabot approval rollout + +The caller in `.github/workflows/dependabot-auto-merge.yml` adopts the same +reviewed research-repo-tools revision as markov-chain-monte-carlo: +[`cbb2ea6`](https://github.com/acgetchell/research-repo-tools/commit/cbb2ea6dee8866b3f0547bca935aef48fdd71707). +It replaces the owner-issued CodeRabbit request that left bot PRs without an +approving review (#258). A successful CodeRabbit status on a skipped review does +not supply approval; the provider's underlying failure to process owner commands +remains unconfirmed. The new workflow removes that approval dependency. + +The trusted base branch supplies exact ecosystem/file allowlists, including the +comparison workspace manifest. Signed Dependabot metadata, verified commits and +base merges, complete file inventories, current-head checks, and approval +deduplication belong to the shared workflow. It never checks out PR code or uses +personal tokens. `.github/dependabot.yml` continues to own dependency eligibility. +See the [shared approval contract][shared-approval]. + +[shared-approval]: https://github.com/acgetchell/research-repo-tools/blob/cbb2ea6dee8866b3f0547bca935aef48fdd71707/docs/AUTOMATING_DEPENDABOT.md + +The settings payloads under `.github/settings/` capture the October 1, 2026 +rollout. They enable Actions approvals with read-only default permissions, add +only the metadata action and reusable workflow to the existing selected-action +allowlist, and enable stale-review dismissal in ruleset `11113227`. All six +required checks, one required approval, resolved threads, strict checks, merge +methods, and the existing bypass actor are preserved. Auto-merge and squash +merging are already enabled. Actions approval is an intentional change from the +old #258 criterion, matching the maintainer's requested MCMC approach. + +Before applying payloads later, reread current settings and reconcile intervening +changes; do not overwrite newer checks or allowlist entries with this snapshot. +The settings can then be applied with: + +```bash +gh api repos/acgetchell/la-stack/actions/permissions/workflow \ + --method PUT --input .github/settings/actions-workflow.json +gh api repos/acgetchell/la-stack/actions/permissions/selected-actions \ + --method PUT --input .github/settings/actions-selected.json +gh api repos/acgetchell/la-stack/rulesets/11113227 \ + --method PUT --input .github/settings/main-ruleset.json +``` + +Merge the caller to `main` before expecting `pull_request_target` to use it. +An old workflow rerun retains its original definition; exercise a new eligible +Dependabot event after deployment. Verify an actual `github-actions[bot]` +approval whose commit ID is the current PR head, native squash merge only after +required checks and threads pass, and continued manual review for ineligible +updates. Record PR, head, approval, workflow-run, and merge links. Installing the +Python package does not deploy or configure this GitHub workflow. + +Merges using `GITHUB_TOKEN` may not trigger push workflows. Dispatch `ci.yml` +against `main` and verify the run's `headSha` equals the merged commit before +claiming default-branch validation. Remove `CODERABBIT_REVIEW_TOKEN` from Actions +and Dependabot secrets only after the old caller has been replaced and no other +workflow consumes it. Local opt-in CodeRabbit CLI review remains separate. diff --git a/docs/dev/docs.md b/docs/dev/docs.md index 6a6b76c..07a9bf7 100644 --- a/docs/dev/docs.md +++ b/docs/dev/docs.md @@ -5,6 +5,7 @@ Detailed documentation rules for [AGENTS.md](../../AGENTS.md). ## Contents - [Document ownership and ordering](#document-ownership-and-ordering) +- [Command discovery and placement](#command-discovery-and-placement) - [Filename conventions](#filename-conventions) - [README and rustdoc guides](#readme-and-rustdoc-guides) - [Links and published destinations](#links-and-published-destinations) @@ -16,6 +17,9 @@ Detailed documentation rules for [AGENTS.md](../../AGENTS.md). - `README.md` owns concise orientation, quickstart, capabilities, and API navigation. Place "Use this crate when" immediately after Introduction. +- `CONTRIBUTING.md` owns prerequisite setup, checks, fixes, tests, security, + and PR preparation. Dedicated task guides own detailed benchmark, coverage, + release, and dependency-maintenance procedures. - `REFERENCES.md` owns bibliographic provenance. Keep the bibliography thematic and preserve citation identifiers and deep links. - `docs/mathematical_basis.md` owns mathematical explanations, assumptions, @@ -33,6 +37,30 @@ Detailed documentation rules for [AGENTS.md](../../AGENTS.md). Update its map when ownership or layout changes; keep command procedures in their existing contributor, benchmark, script, and release guides. +## Command discovery and placement + +Bare `just` generates the complete public recipe list, including arguments and +useful descriptions, directly from Just. Use an explicit private default recipe +so lexicographic source sorting cannot change that action. Keep recipe definitions +sorted lexicographically; do not maintain a second help list or help alias. + +Name recipes by their purpose and preserve equivalent semantics across la-stack, +delaunay, causal-triangulations, and markov-chain-monte-carlo: `check` validates +without changing sources, `fix` applies changes, and `ci` composes final validation. +Tests and security scans belong in contributor guidance. Keep distinct scopes, +feature configurations, and workloads, while removing verified duplicate wrappers +and updating their callers. + +README Quickstart owns useful library usage, bare `just`, and concrete runnable +examples such as `just examples` or `cargo run --locked --example solve_5x5`. +Link prerequisite setup to CONTRIBUTING. This library has no binary; do not add +a `run` recipe solely for consistency with application repositories. + +Discuss individual commands in their owning sections instead of copying the full +recipe list. Release and dependency maintenance remain maintainer workflows in +their dedicated guides. A shared-package upgrade does not rewrite local recipes +or documentation automatically. + ## Filename conventions Under `docs/`, including its subdirectories, name documents by their primary @@ -84,6 +112,10 @@ semantics in the linked guide when shortening a README section. versions for release-specific contracts. docs.rs builds published crates; merging changes does not publish new guide pages. Local rendering does not establish availability on the published site. +- Active repository navigation uses the default branch (`main` here), while + release metadata policy preserves those destinations. Keep measured artifact + links, archived reports, historical release records, and citation provenance + pinned to their original revisions. ## Executable examples diff --git a/justfile b/justfile index 59d93fb..9e3a4bf 100644 --- a/justfile +++ b/justfile @@ -14,6 +14,16 @@ _run := "uv run --locked --no-sync --no-python-downloads research-repo-tools too _coverage_base_args := '''--features exact \ --workspace --lib --tests \ --verbose''' + +# List all public recipes with their arguments and descriptions. +[default] +[private] +_default: + @just --list + +_ensure-actionlint: _ensure-uv + uv run --locked actionlint -version >/dev/null + # System prerequisites remain consumer-owned. _ensure-gh: @command -v gh >/dev/null || { echo "GitHub CLI is required on PATH." >&2; exit 1; } @@ -21,19 +31,16 @@ _ensure-gh: _ensure-jq: @command -v jq >/dev/null || { echo "jq is required on PATH." >&2; exit 1; } -# uv enforces its exact declaration before running the locked CLI. -_ensure-uv: - uv run --locked --no-sync --no-python-downloads research-repo-tools deps check-uv - -_ensure-actionlint: _ensure-uv - uv run --locked actionlint -version >/dev/null - _ensure-shellcheck: _ensure-uv uv run --locked shellcheck --version >/dev/null _ensure-shfmt: _ensure-uv uv run --locked shfmt --version >/dev/null +# uv enforces its exact declaration before running the locked CLI. +_ensure-uv: + uv run --locked --no-sync --no-python-downloads research-repo-tools deps check-uv + _ensure-yamllint: _ensure-uv uv run --locked yamllint --version >/dev/null @@ -53,7 +60,11 @@ action-lint: _ensure-actionlint echo "No workflow files found to lint." fi -# Benchmarks +# Audit the repository's Python and Rust lockfiles with the managed OSV scanner. +audit: + uv run --locked --group dev research-repo-tools security osv uv.lock Cargo.lock + +# Run the fixed-dimension benchmark suites. bench: {{ _run }} cargo bench --locked --workspace --features bench @@ -65,9 +76,7 @@ bench-compare baseline="last" suite="all" scope="release-signal": python-sync baseline={{ quote(baseline) }} {{ _run }} uv run --locked bench-compare "$baseline" --suite {{ quote(suite) }} --scope {{ quote(scope) }} -# Compile benchmarks without running them, treating warnings as errors through -# Cargo so warning policy does not create separate rustc cache artifacts. -# This catches bench/release-profile-only warnings that won't show up in normal debug-profile runs. +# Compile benchmark targets with warnings denied; do not measure timings. bench-compile: CARGO_BUILD_WARNINGS=deny {{ _run }} cargo bench --locked --workspace --no-run --features bench CARGO_BUILD_WARNINGS=deny {{ _run }} cargo bench --locked --no-run --features bench,exact --bench exact @@ -80,10 +89,6 @@ bench-exact: bench-interval: {{ _run }} cargo bench --locked --features bench --bench interval -# Run the certified dot-product and affine-difference benchmark suite. -bench-linear-form: - {{ _run }} cargo bench --locked --features bench --bench linear_form - # Run the cheaper latest measurements used for latest-vs-last reports. bench-latest: bench-vs-linalg-la-stack bench-exact @@ -91,14 +96,18 @@ bench-latest: bench-vs-linalg-la-stack bench-exact bench-latest-vs-last baseline="last": bench-latest python-sync {{ _run }} uv run --locked bench-compare {{ quote(baseline) }} -# Discover all release benchmarks and report their expected measurement budget. -bench-release-inventory: _ensure-uv - {{ _run }} uv run --locked scripts/release_baseline.py inventory +# Run the certified dot-product and affine-difference benchmark suite. +bench-linear-form: + {{ _run }} cargo bench --locked --features bench --bench linear_form # Check every discovered benchmark before the release workflow packages it. bench-release-check tag: _ensure-uv {{ _run }} uv run --locked scripts/release_baseline.py validate --baseline {{ quote(tag) }} +# Discover all release benchmarks and report their expected measurement budget. +bench-release-inventory: _ensure-uv + {{ _run }} uv run --locked scripts/release_baseline.py inventory + # Save a Criterion baseline. Defaults to all release-signal benchmark suites. bench-save-baseline tag suite="all": #!/usr/bin/env bash @@ -136,8 +145,7 @@ bench-vs-linalg filter="": {{ _run }} cargo bench --locked -p la-stack-comparison --features bench --bench vs_linalg fi -# Bench only la-stack rows from the vs_linalg suite for cheap latest-vs-last comparisons. -# Filtered runs omit peer samples, so disable Criterion's complete-group HTML reports. +# Measure only la-stack rows without generating incomplete peer HTML reports. bench-vs-linalg-la-stack: {{ _run }} cargo bench --locked -p la-stack-comparison --features bench --bench vs_linalg -- la_stack --noplot @@ -156,10 +164,11 @@ bench-vs-linalg-quick filter="": {{ _run }} cargo bench --locked -p la-stack-comparison --features bench --bench vs_linalg -- --quick --noplot fi -# Build commands +# Build the library in the debug profile. build: {{ _run }} cargo build +# Build the library in the release profile. build-release: {{ _run }} cargo build --release @@ -204,9 +213,7 @@ check: lint check-fast: {{ _run }} cargo check -# CI simulation: flat GitHub-equivalent union of leaf validators. -# Keep this dependency list explicit so each validation surface runs once without -# re-entering broad check/test bundles. All Cargo targets match the SARIF lint scope. +# Run the complete local CI validation, tests, examples, and benchmark compilation. ci: action-lint zizmor markdown-check spell-check docs-version-check changelog-check toml-parse-check toml-fmt-check toml-lint yaml-fmt-check yaml-lint citation-check validate-json justfile-fmt-check python-format-check python-lint python-fixture-lint python-typecheck test-python cargo-lock-check fmt-check clippy-all-targets doc-check semgrep semgrep-test unused-deps shell-check test-rust-ci test-doc test-doc-exact bench-compile examples @echo "🎯 CI checks complete!" @@ -220,9 +227,7 @@ clean: rm -rf target/llvm-cov rm -rf coverage -# Full Cargo-target Clippy sweep used by `just ci` and the GitHub SARIF workflow. -clippy: clippy-all-targets - +# Check every workspace target with default and all features. clippy-all-targets: {{ _run }} cargo clippy --workspace --all-targets {{ _run }} cargo clippy --workspace --all-targets --all-features @@ -253,19 +258,16 @@ coverage-ci: tools-check mkdir -p coverage {{ _run }} cargo llvm-cov nextest {{ _coverage_base_args }} --cobertura --output-path coverage/cobertura.xml -P coverage -# Default recipe shows available commands -default: - @just --list - # Documentation build checks for the default and exact-feature public APIs. doc-check: RUSTDOCFLAGS='-D warnings' {{ _run }} cargo doc --no-deps RUSTDOCFLAGS='-D warnings' {{ _run }} cargo doc --no-deps --features exact +# Check synchronized release metadata without changing files. docs-version-check: python-sync uv run --locked --group dev research-repo-tools release check -# Examples +# Build and run all library examples, including exact arithmetic. examples: #!/usr/bin/env bash set -euo pipefail @@ -287,104 +289,39 @@ examples: done # Fix (mutating): apply formatters/auto-fixes -fix: toml-fmt fmt python-fix shell-fmt markdown-fix yaml-fix +fix: toml-fix fmt python-fix shell-fix markdown-fix yaml-fix @echo "βœ… Fixes applied!" # Rust formatting fmt: {{ _run }} cargo fmt --all +# Check Rust formatting without changing files. fmt-check: {{ _run }} cargo fmt --all -- --check +# Check workflow syntax and security findings. github-actions-check: action-lint zizmor @echo "βœ… GitHub Actions checks complete!" -help-workflows: - @echo "Common Just workflows:" - @echo " just check # Run lint/validators (non-mutating)" - @echo " just check-fast # Fast compile check (cargo check)" - @echo " just ci # Full CI simulation (check + tests + examples + bench compile)" - @echo " just fix # Apply formatters/auto-fixes (mutating)" - @echo " just setup # Install/verify dev tools + sync Python deps" - @echo "" - @echo "CodeRabbit review (opt-in):" - @echo " just review [base] # Review branch and local changes; verify origin/main by default" - @echo " just review-uncommitted # Review only local changes, including untracked files" - @echo "" - @echo "Benchmarks:" - @echo " just bench # Run benchmarks" - @echo " just bench-compile # Compile benches with warnings-as-errors" - @echo " just bench-latest # Run cheap latest measurements" - @echo " just bench-latest-vs-last # Run latest and compare against last" - @echo " just bench-exact # Run exact-arithmetic benchmarks" - @echo " just bench-interval # Run interval determinant benchmarks" - @echo " just bench-linear-form # Run certified linear-form benchmarks" - @echo " just bench-save-last # Save full baseline as 'last'" - @echo " just bench-vs-linalg # Run vs_linalg bench (optional filter)" - @echo " just bench-vs-linalg-la-stack # Run la-stack rows from vs_linalg" - @echo " just bench-vs-linalg-latest-vs # Run non-exact latest and compare against last" - @echo " just bench-vs-linalg-quick # Quick vs_linalg bench (reduced samples)" - @echo " just performance-doc # Build release docs from retained CSV/JSON" - @echo " just performance-github-assets # Compare stored GitHub Actions release assets" - @echo " just performance-local # Compare current tree against latest release locally" - @echo " just performance-local-non-exact # Compare current non-exact kernels locally" - @echo " just performance-readme # Publish retained release data to README assets/table" - @echo " just performance-release # Measure, retain, and publish release docs" - @echo "" - @echo "Benchmark plotting:" - @echo " just plot-vs-linalg # Plot Criterion results (CSV + SVG + provenance)" - @echo "" - @echo "Changelog & releases:" - @echo " just changelog # Regenerate CHANGELOG.md from full history" - @echo " just changelog-preview # Preview generation without writing" - @echo " just changelog-release # Generate a release with an explicit date" - @echo " just changelog-unreleased # Alias for changelog-release" - @echo " just changelog-archive # Rotate existing changelog history" - @echo " just changelog-check # Validate root and archived release notes" - @echo " just release-notes # Print root or archived release notes" - @echo " just tag # Create annotated tag from CHANGELOG.md" - @echo " just tag-force # Recreate an existing tag" - @echo " just update-version # Update release metadata and infer the previous tag" - @echo "" - @echo "Setup:" - @echo " just setup # Install declared tools, sync dev, and build" - @echo " just setup-tools # Install/verify external tooling" - @echo " just update # Update dependencies and repository-owned tool pins" - @echo "" - @echo "Testing:" - @echo " just coverage # Generate coverage report (HTML)" - @echo " just coverage-ci # Generate coverage for CI (XML)" - @echo " just examples # Run examples" - @echo " just test # Lib + doc tests (fast)" - @echo " just test-all # All tests (Rust + Python)" - @echo " just test-bench-inputs # Benchmark input smoke tests" - @echo " just test-doc # Default-feature doctests" - @echo " just test-exact # Exact-feature tests and doctests" - @echo " just test-integration # Integration tests" - @echo " just test-python # Python tests only (pytest)" - @echo " just test-rust-ci # Release-profile unit + integration CI bucket" - @echo " just test-unit # Library unit tests" - @echo "" - @echo "Note: Some recipes require external tools. Run 'just setup-tools' (tooling) or 'just setup' (full env) first." - # File validation -json-check: validate-json - # Keep the command-memory layer itself canonically formatted. justfile-fmt-check: just --fmt --check -# Lint groups (delaunay-style) +# Check code, documentation, and configuration without changing sources. lint: lint-code lint-docs lint-config +# Check Rust, Python, and shell code without changing sources. lint-code: rust-core-check python-check shell-check -lint-config: json-check toml-ci yaml-ci github-actions-check justfile-fmt-check +# Check JSON, TOML, YAML, workflow security, and Just formatting. +lint-config: validate-json toml-check yaml-check github-actions-check justfile-fmt-check +# Check Markdown, spelling, release metadata, and generated changelogs. lint-docs: markdown-ci docs-version-check changelog-check -# Markdown +# Check active Markdown formatting, local links, and line lengths. markdown-check: tools-check _ensure-uv #!/usr/bin/env bash set -euo pipefail @@ -404,9 +341,11 @@ markdown-check: tools-check _ensure-uv echo "No markdown files found to check." fi +# Check active Markdown and spelling without changing files. markdown-ci: markdown-check spell-check @echo "βœ… Markdown checks complete!" +# Format active Markdown files; preserve generated and archived records. markdown-fix: tools-check #!/usr/bin/env bash set -euo pipefail @@ -426,8 +365,6 @@ markdown-fix: tools-check echo "No markdown files found to format." fi -markdown-lint: markdown-check - # Build release docs from retained scratch inputs or the latest docs/performance snapshot. performance-doc: python-sync {{ _run }} uv run --locked archive-performance --promote-artifacts @@ -507,28 +444,35 @@ plot-vs-linalg metric="lu_solve" stat="median" sample="new" log_y="false" allow_ fi {{ _run }} uv run --locked criterion-dim-plot "${args[@]}" -# Python tooling (uv) +# Check Python formatting, lint, fixtures, and types. python-check: python-format-check python-lint python-fixture-lint python-typecheck +# Check Python tooling and run its consumer integration tests. python-ci: python-format-check python-lint python-fixture-lint python-typecheck test-python @echo "βœ… Python checks complete!" +# Apply Python lint fixes and formatting. python-fix: python-sync uv run --locked ruff check scripts/ --fix uv run --locked ruff format scripts/ tests/semgrep/scripts/ +# Lint deliberate Python fixtures with their declared exceptions. +python-fixture-lint: python-sync + uv run --locked ruff check tests/semgrep/scripts/ + +# Check Python formatting, including static-analysis fixtures. python-format-check: python-sync uv run --locked ruff format --check scripts/ tests/semgrep/scripts/ +# Lint Python support tools and their tests. python-lint: python-sync uv run --locked ruff check scripts/ -python-fixture-lint: python-sync - uv run --locked ruff check tests/semgrep/scripts/ - +# Synchronize the locked Python development environment. python-sync: _ensure-uv uv sync --locked --group dev +# Check all Python support and fixture types; fail on every diagnostic. python-typecheck: python-sync uv run --locked ty check scripts/ tests/semgrep/scripts/ --error all @@ -544,20 +488,17 @@ review base="origin/main": review-uncommitted: uv run --locked --group dev research-repo-tools review uncommitted -# Run the shared dependency and full-history secret scans. -security: security-osv security-secrets +# Check library contracts, docs, static analysis, and dependencies. +rust-core-check: cargo-lock-check fmt-check clippy-core doc-check semgrep semgrep-test unused-deps + @echo "βœ… Rust core checks complete!" -# Audit the repository's Python and Rust lockfiles with the managed OSV scanner. -security-osv: - uv run --locked --group dev research-repo-tools security osv uv.lock Cargo.lock +# Run the shared dependency and full-history secret scans. +security: audit security-secrets # Scan reachable Git history and current tracked/nonignored files with redacted reports. security-secrets: uv run --locked --group dev research-repo-tools security secrets -rust-core-check: cargo-lock-check fmt-check clippy-core doc-check semgrep semgrep-test unused-deps - @echo "βœ… Rust core checks complete!" - # Repository-owned Semgrep rules for project-specific diagnostics. semgrep: _ensure-uv uv run --locked semgrep --metrics off --error --strict --timeout 30 --exclude tests/semgrep/src/project_rules/algebraic_float.rs --config semgrep.yaml . @@ -575,15 +516,7 @@ setup: _ensure-gh _ensure-jq setup-tools: _ensure-gh _ensure-jq uv run --locked --managed-python --only-group tooling research-repo-tools setup -# Check installed tools without synchronization, downloads, or installation. -tools-check: - uv run --locked --no-sync --no-python-downloads research-repo-tools toolchain check - -# Export verified managed paths to GITHUB_ENV in hosted workflows. -tools-export: - uv run --locked --no-sync --no-python-downloads research-repo-tools toolchain export - -# Shell scripts +# Check shell script lint and formatting. shell-check: _ensure-shellcheck _ensure-shfmt #!/usr/bin/env bash set -euo pipefail @@ -600,9 +533,8 @@ shell-check: _ensure-shellcheck _ensure-shfmt echo "No shell files found to check." fi -shell-fix: shell-fmt - -shell-fmt: _ensure-shfmt +# Format maintained shell scripts. +shell-fix: _ensure-shfmt #!/usr/bin/env bash set -euo pipefail files=() @@ -618,8 +550,6 @@ shell-fmt: _ensure-shfmt echo "No shell files found to format." fi -shell-lint: shell-check - # Spell check (typos) spell-check: tools-check #!/usr/bin/env bash @@ -647,9 +577,10 @@ tag version: python-sync tag-force version: python-sync uv run --locked --group dev research-repo-tools changelog tag {{ quote(version) }} --force -# Testing: runnable Rust tests use nextest; rustdoc doctests remain on cargo test. -test: test-lib test-doc +# Run library unit tests and default-feature doctests. +test: test-unit test-doc +# Run all Rust tests, doctests, and Python integration tests. test-all: test-rust test-python @echo "βœ… All tests passed" @@ -657,9 +588,11 @@ test-all: test-rust test-python test-bench-inputs: tools-check {{ _run }} cargo nextest run --workspace --profile ci --features bench,exact --test vs_linalg_inputs --test exact_bench_config --verbose +# Run default-feature Rust doctests. test-doc: {{ _run }} cargo test --doc --verbose +# Run Rust doctests with exact arithmetic enabled. test-doc-exact: {{ _run }} cargo test --features exact --doc --verbose @@ -667,6 +600,7 @@ test-doc-exact: test-exact: tools-check test-doc-exact {{ _run }} cargo nextest run --profile ci --features exact --verbose +# Run Rust integration tests with nextest. test-integration: tools-check {{ _run }} cargo nextest run --profile ci --tests --verbose @@ -674,12 +608,11 @@ test-integration: tools-check test-integration-compile: tools-check {{ _run }} cargo nextest run --all-features --tests --no-run -test-lib: tools-check - {{ _run }} cargo nextest run --profile ci --lib --verbose - +# Run Python consumer integration and benchmark-policy tests. test-python: tools-check python-sync {{ _run }} uv run --locked pytest -q +# Run all runnable Rust tests and both doctest configurations. test-rust: test-rust-ci test-doc test-doc-exact @echo "βœ… Rust tests passed" @@ -687,17 +620,15 @@ test-rust: test-rust-ci test-doc test-doc-exact test-rust-ci: tools-check {{ _run }} cargo nextest run --workspace --release --profile ci --all-features --lib --tests --verbose -test-unit: test-lib +# Run library unit tests with nextest. +test-unit: tools-check + {{ _run }} cargo nextest run --profile ci --lib --verbose -# TOML +# Check TOML parsing, formatting, and lint without changing files. toml-check: toml-parse-check toml-fmt-check toml-lint -toml-ci: toml-check - @echo "βœ… TOML checks complete!" - -toml-fix: toml-fmt - -toml-fmt: tools-check +# Format maintained TOML files. +toml-fix: tools-check #!/usr/bin/env bash set -euo pipefail files=() @@ -712,6 +643,7 @@ toml-fmt: tools-check echo "No TOML files found to format." fi +# Check TOML formatting without changing files. toml-fmt-check: tools-check #!/usr/bin/env bash set -euo pipefail @@ -727,6 +659,7 @@ toml-fmt-check: tools-check echo "No TOML files found to check." fi +# Lint maintained TOML files. toml-lint: tools-check #!/usr/bin/env bash set -euo pipefail @@ -758,6 +691,14 @@ toml-parse-check: python-sync echo "No TOML files found to parse." fi +# Check installed tools without synchronization, downloads, or installation. +tools-check: + uv run --locked --no-sync --no-python-downloads research-repo-tools toolchain check + +# Export verified managed paths to GITHUB_ENV in hosted workflows. +tools-export: + uv run --locked --no-sync --no-python-downloads research-repo-tools toolchain export + # Check for unused direct Cargo dependencies. unused-deps: tools-check {{ _run }} cargo machete @@ -766,12 +707,10 @@ unused-deps: tools-check update: update-tools update-dependencies @echo "βœ… Repository dependencies and tools updated." -# Upgrade uv through its owner, then declared Cargo tools, then synchronize setup. -update-tools: update-uv update-cargo-tools setup-tools - -# Bootstrap outside the project's old uv-version requirement; do not sync here. -update-uv: - uv run --no-config --no-sync --no-python-downloads research-repo-tools deps update-uv +# num-bigint and num-rational share public types and must advance together. +update-cargo-dependencies: + uv run --locked --only-group tooling --inexact research-repo-tools toolchain run -- cargo upgrade --incompatible allow --exclude num-bigint --exclude num-rational + uv run --locked --only-group tooling --inexact research-repo-tools toolchain run -- cargo update # Upgrade only declared managed Cargo tools and publish verified TOML pins. update-cargo-tools: @@ -780,11 +719,6 @@ update-cargo-tools: # Dependency-only updates leave uv and managed Cargo tool pins unchanged. update-dependencies: update-cargo-dependencies update-python-dependencies -# num-bigint and num-rational share public types and must advance together. -update-cargo-dependencies: - uv run --locked --only-group tooling --inexact research-repo-tools toolchain run -- cargo upgrade --incompatible allow --exclude num-bigint --exclude num-rational - uv run --locked --only-group tooling --inexact research-repo-tools toolchain run -- cargo update - # Advance direct dev pins, refresh the whole lock, and explicitly synchronize dev. update-python-dependencies: uv run --locked --only-group tooling --inexact research-repo-tools deps update-python @@ -793,12 +727,20 @@ update-python-dependencies: alias update-python-deps := update-python-dependencies +# Upgrade uv through its owner, then declared Cargo tools, then synchronize setup. +update-tools: update-uv update-cargo-tools setup-tools + +# Bootstrap outside the project's old uv-version requirement; do not sync here. +update-uv: + uv run --no-config --no-sync --no-python-downloads research-repo-tools deps update-uv + # Update deterministic release metadata, inferring the previous stable published GitHub release. [doc('Update package, citation, lockfile, and non-artifact documentation release versions.')] [positional-arguments] update-version tag *args: _ensure-gh python-sync {{ _run }} research-repo-tools release update "$@" +# Check maintained JSON syntax. validate-json: _ensure-jq #!/usr/bin/env bash set -euo pipefail @@ -814,12 +756,10 @@ validate-json: _ensure-jq echo "No JSON files found to validate." fi -# YAML +# Check YAML and CFF formatting and lint. yaml-check: yaml-fmt-check yaml-lint -yaml-ci: yaml-check citation-check - @echo "βœ… YAML/CFF checks complete!" - +# Format maintained YAML and CFF files. yaml-fix: tools-check #!/usr/bin/env bash set -euo pipefail @@ -835,6 +775,7 @@ yaml-fix: tools-check echo "No YAML files found to format." fi +# Check YAML and CFF formatting without changing files. yaml-fmt-check: tools-check #!/usr/bin/env bash set -euo pipefail @@ -850,6 +791,7 @@ yaml-fmt-check: tools-check echo "No YAML files found to check." fi +# Lint maintained YAML and CFF files. yaml-lint: _ensure-yamllint #!/usr/bin/env bash set -euo pipefail @@ -866,6 +808,7 @@ yaml-lint: _ensure-yamllint echo "No YAML files found to lint." fi -# GitHub Actions security analysis -zizmor: tools-check - @{{ _run }} bash scripts/run_zizmor.sh +# Audit workflows with the declared scanner and shared authentication policy. +[positional-arguments] +zizmor *args: + uv run --locked --group dev research-repo-tools zizmor check "$@" diff --git a/pyproject.toml b/pyproject.toml index c864e6f..6a4b228 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,8 +24,8 @@ classifiers = [ "Topic :: System :: Benchmarking", ] -# No runtime dependencies currently; scripts rely on the standard library. -dependencies = [ ] +# Benchmark adapters use only the installed shared package and the standard library. +dependencies = [ "research-repo-tools==0.1.7" ] [project.urls] "Homepage" = "https://github.com/acgetchell/la-stack" @@ -41,7 +41,7 @@ criterion-dim-plot = "criterion_dim_plot:main" # Configure setuptools to find modules in scripts/ directory. [tool.setuptools] package-dir = { "" = "scripts" } -py-modules = [ "archive_performance", "bench_compare", "benchmark_contract", "benchmark_summaries", "criterion_dim_plot", "criterion_measurements", "performance_artifacts", "subprocess_utils" ] +py-modules = [ "archive_performance", "bench_compare", "benchmark_contract", "benchmark_process", "benchmark_summaries", "criterion_dim_plot", "criterion_measurements", "performance_artifacts" ] [tool.ruff] line-length = 160 @@ -88,11 +88,11 @@ known-first-party = [ "archive_performance", "bench_compare", "benchmark_contract", + "benchmark_process", "benchmark_summaries", "criterion_dim_plot", "criterion_measurements", "performance_artifacts", - "subprocess_utils", ] force-single-line = false split-on-trailing-comma = true @@ -185,6 +185,9 @@ formatter = "changelog-rumdl.toml" # Packaged 0.1.7 template plus one deps-dev body-preservation exception. cliff-config = "cliff.toml" +[tool.research-repo-tools.zizmor] +persona = "regular" + [tool.research-repo-tools.release] # Release preparation and post-merge tagging can happen on different UTC days. date-policy = "declared" @@ -195,9 +198,9 @@ exclude = [ "docs/archive/**", "docs/performance/**", "docs/performance.md" ] [[tool.research-repo-tools.release.rules]] path = "README.md" -pattern = 'https://(?:github\.com/acgetchell/la-stack/(?:blob|raw|tree)/|raw\.githubusercontent\.com/acgetchell/la-stack/)(?Pv[0-9]+\.[0-9]+\.[0-9]+|[0-9a-f]{7,40})/[^\s)\]>"?]+' -source = "tag" -count = 13 +pattern = 'https://(?:github\.com/acgetchell/la-stack/(?:blob|raw|tree)/|raw\.githubusercontent\.com/acgetchell/la-stack/)(?Pmain|v[0-9]+\.[0-9]+\.[0-9]+|[0-9a-f]{7,40})/[^\s)\]>"?]+' +value = "main" +count = 30 exclude = '/docs/assets/bench/' [[tool.research-repo-tools.release.rules]] diff --git a/scripts/README.md b/scripts/README.md index 1c6ec51..5287910 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -261,7 +261,8 @@ just update-version vX.Y.Z The shared `research-repo-tools release update` CLI infers the previous stable release from published GitHub releases and updates package, lockfile, citation, -and non-artifact README references transactionally. It records the current UTC +and release metadata transactionally. Active README navigation remains on `main`; +measured artifact links retain their recorded revision. It records the current UTC date in `CITATION.cff`. Pass `--previous-release vA.B.C` to avoid release discovery, `--date YYYY-MM-DD` to declare a date, or `--dry-run` to preview validated changes: @@ -386,12 +387,22 @@ preview an annotation without creating a tag. | Script | Purpose | |---|---| | `archive_performance.py` | Promote release performance docs and archive older comparisons | -| `performance_artifacts.py` | Validate and publish schema-versioned performance-comparison CSV/JSON inputs | | `bench_compare.py` | Compare Criterion benchmark baselines and render Markdown reports | +| `benchmark_contract.py` | Define and hash the consumer's benchmark inventory contract | +| `benchmark_process.py` | Thin benchmark phase adapters over the published shared process API, plus consumer root selection | | `benchmark_summaries.py` | Preserve every local case summary, bind provenance, and resolve saved report inputs after cleanup | | `criterion_dim_plot.py` | Plot Criterion benchmark results (CSV + SVG + README table) | | `criterion_measurements.py` | Validate full Criterion sampling and estimates for local summaries and hosted archives | +| `performance_artifacts.py` | Validate and publish schema-versioned performance-comparison CSV/JSON inputs | | `release_baseline.py` | Inventory full Criterion suites and validate complete raw release baselines before packaging | -| `subprocess_utils.py` | Safe subprocess wrappers for git commands | + +Shared process discovery, execution, byte transport, CPU detection, diagnostics, +and zizmor authentication belong to research-repo-tools. The former +`subprocess_utils.py` and `run_zizmor.sh` implementations and their duplicated +unit tests are removed. Consumer tests retain native adapter checks, benchmark +contracts, caller file-policy coverage, and recipe forwarding. The remaining +performance modules own la-stack's retained schemas, scientific eligibility, +benchmark inventories, and report layouts; shared primitives are not a drop-in +replacement for those contracts. See `docs/RELEASING.md` for the full release workflow. diff --git a/scripts/archive_performance.py b/scripts/archive_performance.py index 755d0d4..d1f5f32 100644 --- a/scripts/archive_performance.py +++ b/scripts/archive_performance.py @@ -36,8 +36,11 @@ from pathlib import Path from typing import Any, Literal, cast +from research_repo_tools.process import ExecutableNotFoundError, cpu_description, format_exception_diagnostics, run_git_bytes + from bench_compare import HOW_TO_UPDATE_SECTION, render_release_artifacts from benchmark_contract import benchmark_contract_digest +from benchmark_process import run_git_command, run_safe_command from benchmark_summaries import report_input_paths, resolve_report_paths, retained_outputs, summary_outputs from performance_artifacts import ( NO_API_COMPATIBILITY, @@ -47,14 +50,6 @@ publish_bundle, resolve_shared_harness_compatibility, ) -from subprocess_utils import ( - ExecutableNotFoundError, - cpu_description, - format_exception_diagnostics, - run_git_command, - run_git_command_with_input, - run_safe_command, -) _VERSION_RE = re.compile(r"^\*\*la-stack\*\* v(?P[^\s`]+)", re.MULTILINE) _BASELINE_RE = re.compile(r"^Comparison against baseline \*\*(?P[^*]+)\*\*:", re.MULTILINE) @@ -554,21 +549,15 @@ def update_archive_index(archive_dir: Path) -> None: def _format_command_failure(command: list[str], exc: subprocess.CalledProcessError) -> str: - parts = [f"command failed ({exc.returncode}): {' '.join(command)}"] - if exc.stdout: - parts.append(f"stdout:\n{exc.stdout.strip()}") - if exc.stderr: - parts.append(f"stderr:\n{exc.stderr.strip()}") - return "\n".join(parts) + header = f"command failed ({exc.returncode}): {' '.join(command)}" + details = format_exception_diagnostics(exc).partition("\n")[2] + return f"{header}\n{details}" if details else header def _format_command_timeout(command: list[str], exc: subprocess.TimeoutExpired) -> str: - parts = [f"command timed out after {exc.timeout} seconds: {' '.join(command)}"] - if exc.stdout: - parts.append(f"stdout:\n{str(exc.stdout).strip()}") - if exc.stderr: - parts.append(f"stderr:\n{str(exc.stderr).strip()}") - return "\n".join(parts) + header = f"command timed out after {exc.timeout} seconds: {' '.join(command)}" + details = format_exception_diagnostics(exc).partition("\n")[2] + return f"{header}\n{details}" if details else header def _format_command_start_failure(command: list[str], exc: BaseException) -> str: @@ -1343,7 +1332,7 @@ def _apply_current_diff_to_worktree(*, repo_root: Path, worktree: Path) -> None: diff = patch_path.read_bytes() if diff.strip(): try: - run_git_command_with_input(["apply", "--binary"], diff, cwd=worktree) + run_git_bytes(["apply", "--binary"], input=diff, cwd=worktree) except subprocess.CalledProcessError as exc: raise RuntimeError(_format_command_failure(["git", "apply", "--binary"], exc)) from exc except subprocess.TimeoutExpired as exc: diff --git a/scripts/bench_compare.py b/scripts/bench_compare.py index 9301a9d..3ad4815 100644 --- a/scripts/bench_compare.py +++ b/scripts/bench_compare.py @@ -37,6 +37,9 @@ from pathlib import Path from typing import Literal, Protocol, cast +from research_repo_tools.process import ExecutableNotFoundError, format_exception_diagnostics + +from benchmark_process import find_project_root, run_git_command from criterion_dim_plot import METRICS from performance_artifacts import ( PRE_RATIONAL_INPUT_API_COMPATIBILITY, @@ -53,7 +56,6 @@ publish_bundle, resolve_shared_harness_compatibility, ) -from subprocess_utils import ExecutableNotFoundError, find_project_root, format_exception_diagnostics, run_git_command # --------------------------------------------------------------------------- # Benchmark group / bench discovery diff --git a/scripts/benchmark_process.py b/scripts/benchmark_process.py new file mode 100644 index 0000000..e27120f --- /dev/null +++ b/scripts/benchmark_process.py @@ -0,0 +1,68 @@ +"""Benchmark command adapters around the published shared process API. + +The shared package owns discovery, execution, exact byte transport, CPU metadata, +and diagnostics. These adapters retain existing benchmark call signatures and +the distinction between captured commands and live measurement progress. +""" + +import subprocess +from pathlib import Path +from typing import TYPE_CHECKING, TypedDict, Unpack + +from research_repo_tools.process import run_command, run_command_live + +if TYPE_CHECKING: + from collections.abc import Mapping + + +class CommandOptions(TypedDict, total=False): + """Options needed by captured plotting and live measurement phases.""" + + env: Mapping[str, str] | None + check: bool + timeout: float | None + capture_output: bool + input: str + + +def run_safe_command( + command: str, + args: list[str], + cwd: Path | None = None, + **options: Unpack[CommandOptions], +) -> subprocess.CompletedProcess[str]: + """Select the shared captured or live runner for a benchmark phase.""" + env = options.get("env") + check = options.get("check", True) + timeout = options.get("timeout", 300.0) + if options.get("capture_output", True): + return run_command(command, args, cwd=cwd, env=env, check=check, timeout=timeout, input=options.get("input")) + if "input" in options: + msg = "Live benchmark commands require inherited stdin" + raise ValueError(msg) + result = run_command_live(command, args, cwd=cwd, env=env, check=check, timeout=timeout) + return subprocess.CompletedProcess(result.args, result.returncode, "", "") + + +def run_git_command( + args: list[str], + cwd: Path | None = None, + *, + check: bool = True, + timeout: float | None = 300.0, + env: Mapping[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + """Capture benchmark Git metadata without opening a pager.""" + return run_command("git", ["--no-pager", *args], cwd=cwd, check=check, timeout=timeout, env=env) + + +def find_project_root(start: Path | None = None) -> Path: + """Find the benchmark consumer's nearest Cargo manifest from its working directory.""" + directory = (start or Path.cwd()).resolve() + if directory.is_file(): + directory = directory.parent + for candidate in (directory, *directory.parents): + if (candidate / "Cargo.toml").is_file(): + return candidate + msg = "Could not locate Cargo.toml to determine benchmark root" + raise FileNotFoundError(msg) diff --git a/scripts/criterion_dim_plot.py b/scripts/criterion_dim_plot.py index b4561b1..a0e4430 100644 --- a/scripts/criterion_dim_plot.py +++ b/scripts/criterion_dim_plot.py @@ -29,10 +29,12 @@ from pathlib import Path from typing import Final, Protocol, TypeGuard, cast +from research_repo_tools.process import ExecutableNotFoundError, cpu_description + from benchmark_contract import benchmark_contract_digest +from benchmark_process import find_project_root, run_git_command, run_safe_command from benchmark_summaries import report_input_paths, resolve_report_paths from performance_artifacts import ArtifactPaths, PerformanceBundle, TimingEstimate, ensure_distinct_paths, load_bundle -from subprocess_utils import ExecutableNotFoundError, cpu_description, find_project_root, run_git_command, run_safe_command @dataclass(frozen=True, slots=True) diff --git a/scripts/release_baseline.py b/scripts/release_baseline.py index 8ce34ce..14880e1 100644 --- a/scripts/release_baseline.py +++ b/scripts/release_baseline.py @@ -8,6 +8,8 @@ import sys from pathlib import Path +from research_repo_tools.process import ExecutableNotFoundError, format_exception_diagnostics, run_command + from bench_compare import ( EXACT_GROUPS, VS_LINALG_CANONICAL_DIMS, @@ -15,7 +17,6 @@ VS_LINALG_STANDARD_BENCH_ORDER, ) from criterion_measurements import read_object, validate_measurement -from subprocess_utils import ExecutableNotFoundError, format_exception_diagnostics, run_cargo_command SUITES = {"vs_linalg": "bench", "exact": "bench,exact"} RAW_FILES = ("benchmark.json", "estimates.json", "sample.json", "tukey.json") @@ -68,11 +69,10 @@ def discover(root: Path, manifest: Path, criterion: Path) -> None: for suite, features in SUITES.items(): print(f"[release-baseline] Discovering {suite}", flush=True) package = ["-p", "la-stack-comparison"] if suite == "vs_linalg" else [] - result = run_cargo_command( + result = run_command( + "cargo", ["bench", "--locked", *package, "--features", features, "--bench", suite, "--", "--list"], cwd=root, - capture_output=False, - stdout=subprocess.PIPE, timeout=None, # The workflow bounds compilation and discovery together. ) inventory[suite] = parse_benchmark_list(result.stdout) diff --git a/scripts/run_zizmor.sh b/scripts/run_zizmor.sh deleted file mode 100644 index cbf73c8..0000000 --- a/scripts/run_zizmor.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -# Keep authentication out of command arguments and shell traces. -set +x -set -euo pipefail - -if [[ "${ZIZMOR_OFFLINE:-false}" == true || "${ZIZMOR_NO_ONLINE_AUDITS:-false}" == true ]]; then - echo "zizmor: offline audits requested; online audits are disabled." - exec zizmor --offline --persona regular .github -fi - -zizmor_token="${ZIZMOR_GITHUB_TOKEN:-${GH_TOKEN:-${GITHUB_TOKEN:-}}}" -if [[ -z "$zizmor_token" ]] && command -v gh >/dev/null; then - if resolved_token="$(gh auth token 2>/dev/null)"; then - zizmor_token="$resolved_token" - fi -fi - -if [[ -n "$zizmor_token" ]]; then - echo "zizmor: running authenticated online audits (persona: regular)." - # Normalize GH_TOKEN too: zizmor checks it before ZIZMOR_GITHUB_TOKEN. - export GH_TOKEN="$zizmor_token" ZIZMOR_GITHUB_TOKEN="$zizmor_token" - exec zizmor --persona regular .github -fi - -echo "zizmor: no GitHub token available; using offline audits. Online findings are not checked." -exec zizmor --offline --persona regular .github diff --git a/scripts/subprocess_utils.py b/scripts/subprocess_utils.py deleted file mode 100644 index cf350a9..0000000 --- a/scripts/subprocess_utils.py +++ /dev/null @@ -1,370 +0,0 @@ -#!/usr/bin/env python3 -"""Secure subprocess utilities for Python scripts. - -This module provides secure subprocess wrappers that: -- Use full executable paths instead of command names -- Validate executables exist before running -- Provide consistent error handling -- Mitigate security vulnerabilities flagged by Bandit - -All scripts should use these functions instead of calling subprocess directly. - -Ported from the delaunay project's scripts/subprocess_utils.py (minimal subset). -""" - -import os -import platform -import shutil -import subprocess -import tempfile -from pathlib import Path -from typing import Any, cast - -type RunKwargs = dict[str, Any] - -DEFAULT_COMMAND_TIMEOUT_SECONDS = 300.0 -_GENERIC_CPU_NAMES = frozenset({"amd64", "arm", "arm64", "aarch64", "i386", "i686", "unknown", "x86_64"}) - - -class ExecutableNotFoundError(Exception): - """Raised when a required executable is not found in PATH.""" - - -def _diagnostic_stream(value: str | bytes | None) -> str: - """Return captured subprocess output as stripped, readable text.""" - if value is None: - return "" - if isinstance(value, bytes): - return value.decode("utf-8", errors="replace").strip() - return value.strip() - - -def _diagnostic_command(value: object) -> str: - """Render a subprocess command without Python container syntax.""" - if isinstance(value, (list, tuple)): - return " ".join(str(part) for part in value) - return str(value) - - -def format_exception_diagnostics(error: BaseException) -> str: - """Render expected CLI failures without discarding nested diagnostics.""" - if isinstance(error, BaseExceptionGroup): - lines = [f"{error.message} ({len(error.exceptions)} sub-exceptions):"] - for index, child in enumerate(error.exceptions, start=1): - detail_lines = format_exception_diagnostics(child).splitlines() or [child.__class__.__name__] - lines.append(f" {index}. {detail_lines[0]}") - lines.extend(f" {line}" for line in detail_lines[1:]) - return "\n".join(lines) - - if isinstance(error, subprocess.CalledProcessError): - parts = [f"command failed with exit status {error.returncode}: {_diagnostic_command(error.cmd)}"] - stdout = _diagnostic_stream(error.stdout) - stderr = _diagnostic_stream(error.stderr) - if stdout: - parts.append(f"stdout:\n{stdout}") - if stderr: - parts.append(f"stderr:\n{stderr}") - return "\n".join(parts) - - if isinstance(error, subprocess.TimeoutExpired): - parts = [f"command timed out after {error.timeout} seconds: {_diagnostic_command(error.cmd)}"] - stdout = _diagnostic_stream(error.stdout) - stderr = _diagnostic_stream(error.stderr) - if stdout: - parts.append(f"stdout:\n{stdout}") - if stderr: - parts.append(f"stderr:\n{stderr}") - return "\n".join(parts) - - return str(error) - - -def get_safe_executable(command: str) -> str: - """Get the full path to an executable, validating it exists. - - Args: - command: Command name to find (e.g., "git") - - Returns: - Full path to the executable - - Raises: - ExecutableNotFoundError: If executable is not found in PATH - """ - full_path = shutil.which(command) - if full_path is None: - raise ExecutableNotFoundError(f"Required executable '{command}' not found in PATH") - return full_path - - -def _build_run_kwargs(function_name: str, **kwargs: Any) -> RunKwargs: - """Build secure kwargs for subprocess.run with consistent hardening. - - Args: - function_name: Name of the calling function (for error messages) - **kwargs: User-provided kwargs to validate and merge - - Returns: - Validated and hardened kwargs dict for subprocess.run - - Raises: - ValueError: If insecure parameters are provided - """ - # Disallow shell=True to preserve security guarantees - if kwargs.get("shell"): - msg = f"shell=True is not allowed in {function_name}" - raise ValueError(msg) - # Disallow overriding the program to execute - if "executable" in kwargs: - msg = f"Overriding 'executable' is not allowed in {function_name}" - raise ValueError(msg) - # Enforce text mode for stable typing (CompletedProcess[str]) - kwargs.pop("text", None) - run_kwargs = { - "capture_output": True, - "text": True, - "check": True, # Secure default - **kwargs, # Allow overriding other safe defaults - } - # Prefer deterministic UTF-8 unless caller overrides - run_kwargs.setdefault("encoding", "utf-8") - run_kwargs.setdefault("timeout", DEFAULT_COMMAND_TIMEOUT_SECONDS) - return run_kwargs - - -def run_git_command( - args: list[str], - cwd: Path | None = None, - **kwargs: Any, -) -> subprocess.CompletedProcess[str]: - """Run a git command securely using full executable path. - - Args: - args: Git command arguments (without 'git' prefix) - cwd: Working directory for the command - **kwargs: Additional arguments passed to subprocess.run - - Returns: - CompletedProcess result - - Raises: - ExecutableNotFoundError: If git is not found - subprocess.CalledProcessError: If command fails and check=True - subprocess.TimeoutExpired: If command times out - """ - git_path = get_safe_executable("git") - run_kwargs = _build_run_kwargs("run_git_command", **kwargs) - return cast( - "subprocess.CompletedProcess[str]", - subprocess.run( # noqa: S603,PLW1510 - [git_path, *args], - cwd=cwd, - **run_kwargs, - ), - ) - - -def run_cargo_command( - args: list[str], - cwd: Path | None = None, - **kwargs: Any, -) -> subprocess.CompletedProcess[str]: - """Run a cargo command securely using full executable path. - - Args: - args: Cargo command arguments (without 'cargo' prefix) - cwd: Working directory for the command - **kwargs: Additional arguments passed to subprocess.run - - Returns: - CompletedProcess result - - Raises: - ExecutableNotFoundError: If cargo is not found - subprocess.CalledProcessError: If command fails and check=True - subprocess.TimeoutExpired: If command times out - """ - cargo_path = get_safe_executable("cargo") - run_kwargs = _build_run_kwargs("run_cargo_command", **kwargs) - return cast( - "subprocess.CompletedProcess[str]", - subprocess.run( # noqa: S603,PLW1510 - [cargo_path, *args], - cwd=cwd, - **run_kwargs, - ), - ) - - -def run_safe_command( - command: str, - args: list[str], - cwd: Path | None = None, - **kwargs: Any, -) -> subprocess.CompletedProcess[str]: - """Run any command securely using full executable path. - - Args: - command: Command name to run (for example, "rustc" or "gnuplot") - args: Command arguments - cwd: Working directory for the command - **kwargs: Additional arguments passed to subprocess.run - - Returns: - CompletedProcess result - - Raises: - ExecutableNotFoundError: If command is not found - subprocess.CalledProcessError: If command fails and check=True - subprocess.TimeoutExpired: If command times out - """ - command_path = get_safe_executable(command) - run_kwargs = _build_run_kwargs(f"run_safe_command for {command}", **kwargs) - return cast( - "subprocess.CompletedProcess[str]", - subprocess.run( # noqa: S603,PLW1510 - [command_path, *args], - cwd=cwd, - **run_kwargs, - ), - ) - - -def _darwin_cpu_model() -> str: - try: - return run_safe_command("sysctl", ["-n", "machdep.cpu.brand_string"]).stdout.strip() - except ( - ExecutableNotFoundError, - OSError, - subprocess.CalledProcessError, - subprocess.TimeoutExpired, - ): - return "" - - -def _linux_cpu_model() -> str: - try: - for line in Path("/proc/cpuinfo").read_text(encoding="utf-8").splitlines(): - field, separator, value = line.partition(":") - if separator and field.strip().casefold() in {"model name", "hardware"} and value.strip(): - return value.strip() - except OSError: - pass - return "" - - -def cpu_description() -> str: - """Return a reproducible processor model plus architecture when available.""" - machine = platform.machine().strip() - model_by_system = { - "Darwin": _darwin_cpu_model, - "Linux": _linux_cpu_model, - "Windows": lambda: os.environ.get("PROCESSOR_IDENTIFIER", "").strip(), - } - model_factory = model_by_system.get(platform.system()) - model = "" if model_factory is None else model_factory() - - processor = platform.processor().strip() - if not model and processor.casefold() not in _GENERIC_CPU_NAMES: - model = processor - if not model: - return "unavailable" - if machine and machine.casefold() not in model.casefold(): - return f"{model} ({machine})" - return model - - -def get_git_commit_hash(cwd: Path | None = None) -> str: - """Get the current git commit hash.""" - result = run_git_command(["rev-parse", "HEAD"], cwd=cwd) - return result.stdout.strip() - - -def get_git_remote_url(remote: str = "origin", cwd: Path | None = None) -> str: - """Get the URL of a git remote.""" - result = run_git_command(["remote", "get-url", remote], cwd=cwd) - return result.stdout.strip() - - -def check_git_repo() -> bool: - """Return true when the current directory is inside a git repository.""" - try: - run_git_command(["rev-parse", "--git-dir"]) - except ( - ExecutableNotFoundError, - subprocess.CalledProcessError, - ): - return False - else: - return True - - -def check_git_history() -> bool: - """Return true when the current git repository has at least one commit.""" - try: - run_git_command(["log", "--oneline", "-n", "1"]) - except ( - ExecutableNotFoundError, - subprocess.CalledProcessError, - ): - return False - else: - return True - - -def run_git_command_with_input( - args: list[str], - input_data: str | bytes, - cwd: Path | None = None, - **kwargs: Any, -) -> subprocess.CompletedProcess[str]: - """Run a git command securely with stdin input using full executable path. - - Args: - args: Git command arguments (without 'git' prefix) - input_data: Text to encode or bytes to send to stdin without translation - cwd: Working directory for the command - **kwargs: Additional arguments passed to subprocess.run - - Returns: - CompletedProcess result - - Raises: - ExecutableNotFoundError: If git is not found - subprocess.CalledProcessError: If command fails and check=True - subprocess.TimeoutExpired: If command times out - """ - git_path = get_safe_executable("git") - run_kwargs = _build_run_kwargs("run_git_command_with_input", **kwargs) - encoding = cast("str", run_kwargs.get("encoding") or "utf-8") - errors = cast("str", run_kwargs.get("errors") or "strict") - payload = input_data if isinstance(input_data, bytes) else input_data.encode(encoding, errors) - with tempfile.TemporaryFile() as stdin: - stdin.write(payload) - stdin.seek(0) - return cast( - "subprocess.CompletedProcess[str]", - subprocess.run( # noqa: S603,PLW1510 - [git_path, *args], - cwd=cwd, - stdin=stdin, - **run_kwargs, - ), - ) - - -class ProjectRootNotFoundError(Exception): - """Raised when project root directory cannot be located.""" - - -def find_project_root(start: Path | None = None) -> Path: - """Find the nearest project root by walking upward to ``Cargo.toml``.""" - project_root = (start or Path.cwd()).resolve() - if project_root.is_file(): - project_root = project_root.parent - while project_root != project_root.parent: - if (project_root / "Cargo.toml").is_file(): - return project_root - project_root = project_root.parent - msg = "Could not locate Cargo.toml to determine project root" - raise ProjectRootNotFoundError(msg) diff --git a/scripts/tests/test_archive_performance.py b/scripts/tests/test_archive_performance.py index afd5ada..c817c57 100644 --- a/scripts/tests/test_archive_performance.py +++ b/scripts/tests/test_archive_performance.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Never import pytest +from research_repo_tools.process import run_command import archive_performance from archive_performance import ( @@ -34,7 +35,6 @@ TimingEstimate, write_bundle, ) -from subprocess_utils import run_cargo_command if TYPE_CHECKING: from collections.abc import Sequence @@ -294,7 +294,8 @@ def test_shared_harness_preserves_manifest_example_targets(tmp_path: Path, examp ) digest = archive_performance._install_shared_benchmark_harness(source=current, destination=baseline) - run_cargo_command( + run_command( + "cargo", [ "test", "--offline", @@ -314,7 +315,8 @@ def test_shared_harness_preserves_manifest_example_targets(tmp_path: Path, examp cwd=Path(__file__).resolve().parents[2], ) metadata = json.loads( - run_cargo_command( + run_command( + "cargo", ["metadata", "--offline", "--no-deps", "--format-version", "1", "--manifest-path", str(baseline / "Cargo.toml")], cwd=Path(__file__).resolve().parents[2], ).stdout @@ -426,7 +428,8 @@ def test_complete_summaries_reject_duplicate_criterion_identities(tmp_path: Path collect_measurements(tmp_path, "v0.4.5") -def test_apply_current_diff_includes_only_tracked_changes_without_mutating_index(tmp_path: Path) -> None: +@pytest.mark.parametrize("line_ending", ["\n", "\r\n"], ids=["lf", "crlf"]) +def test_apply_current_diff_includes_only_tracked_changes_without_mutating_index(tmp_path: Path, line_ending: str) -> None: repo_root = tmp_path / "repo" worktree = tmp_path / "worktree" repo_root.mkdir() @@ -434,17 +437,18 @@ def test_apply_current_diff_includes_only_tracked_changes_without_mutating_index _git(repo_root, "config", "user.name", "Test User") _git(repo_root, "config", "user.email", "test@example.com") _git(repo_root, "config", "commit.gpgsign", "false") + _git(repo_root, "config", "core.autocrlf", "false") (repo_root / ".gitignore").write_text("ignored.bin\n", encoding="utf-8") tracked = repo_root / "tracked.txt" - tracked.write_text("committed\n", encoding="utf-8") + tracked.write_text("committed\n", encoding="utf-8", newline=line_ending) _git(repo_root, "add", "--", ".gitignore", "tracked.txt") _git(repo_root, "commit", "--quiet", "-m", "initial") _git(repo_root, "worktree", "add", "--quiet", "--detach", str(worktree), "HEAD") - tracked.write_text("staged\n", encoding="utf-8") + tracked.write_text("staged\n", encoding="utf-8", newline=line_ending) _git(repo_root, "add", "--", "tracked.txt") - tracked.write_text("working tree\n", encoding="utf-8") + tracked.write_text("working tree\n", encoding="utf-8", newline=line_ending) binary_payload = bytes(range(256)) * 2 binary = repo_root / "--untracked.bin" binary.write_bytes(binary_payload) @@ -458,11 +462,11 @@ def test_apply_current_diff_includes_only_tracked_changes_without_mutating_index status_before = _git(repo_root, "status", "--porcelain=v1", "--untracked-files=all") archive_performance._apply_current_diff_to_worktree(repo_root=repo_root, worktree=worktree) - assert (worktree / "tracked.txt").read_text(encoding="utf-8") == "working tree\n" + assert (worktree / "tracked.txt").read_bytes() == f"working tree{line_ending}".encode() assert not (worktree / binary.name).exists() assert not (worktree / link.name).exists() assert not (worktree / "ignored.bin").exists() - assert _git(repo_root, "show", ":tracked.txt") == "staged\n" + assert _git(repo_root, "show", ":tracked.txt") == f"staged{line_ending}" assert _git(repo_root, "status", "--porcelain=v1", "--untracked-files=all") == status_before @@ -536,11 +540,22 @@ def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> ("failure", "message"), [ (archive_performance.ExecutableNotFoundError("missing tool"), "command could not start: tool --flag: missing tool"), - (subprocess.TimeoutExpired(["tool", "--flag"], 17, stderr="stalled"), "command timed out after 17 seconds: tool --flag"), + ( + subprocess.TimeoutExpired(["tool", "--flag"], 17, stderr="stalled"), + "command timed out after 17 seconds: tool --flag\nstderr:\nstalled", + ), + ( + subprocess.CalledProcessError(1, ["tool", "--flag"], output=b"partial output\n", stderr=b"failed\n"), + "command failed (1): tool --flag\nstdout:\npartial output\nstderr:\nfailed", + ), + ( + subprocess.TimeoutExpired(["tool", "--flag"], 17, output=b"partial output\n", stderr=b"stalled\n"), + "command timed out after 17 seconds: tool --flag\nstdout:\npartial output\nstderr:\nstalled", + ), (OSError("working directory unavailable"), "command could not start: tool --flag: working directory unavailable"), ], ) -def test_run_tool_normalizes_launch_timeout_and_os_errors( +def test_run_tool_normalizes_command_failures( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: Exception, @@ -554,7 +569,7 @@ def fail_run(*_args: object, **_kwargs: object) -> SimpleNamespace: with pytest.raises(RuntimeError) as exc_info: archive_performance._run_tool("tool", ["--flag"], cwd=tmp_path) - assert str(exc_info.value).startswith(message) + assert str(exc_info.value) == message assert exc_info.value.__cause__ is failure @@ -1618,7 +1633,7 @@ def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> _write_current_benchmark_tooling(worktree) return _result() - def fake_run_git_with_input(args: Sequence[str], input_data: str, cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: + def fake_run_git_with_input(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: calls.append(("git-stdin", tuple(args), cwd)) return _result() @@ -1635,7 +1650,7 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** monkeypatch.chdir(tmp_path) monkeypatch.setattr(archive_performance, "run_git_command", fake_run_git) - monkeypatch.setattr(archive_performance, "run_git_command_with_input", fake_run_git_with_input) + monkeypatch.setattr(archive_performance, "run_git_bytes", fake_run_git_with_input) monkeypatch.setattr(archive_performance, "run_safe_command", fake_run_safe) rc = main( @@ -1707,7 +1722,7 @@ def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> _write_current_benchmark_tooling(worktree) return _result() - def fake_run_git_with_input(args: Sequence[str], input_data: str, cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: + def fake_run_git_with_input(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: calls.append(("git-stdin", tuple(args), cwd)) return _result() @@ -1724,7 +1739,7 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** monkeypatch.chdir(tmp_path) monkeypatch.setattr(archive_performance, "run_git_command", fake_run_git) - monkeypatch.setattr(archive_performance, "run_git_command_with_input", fake_run_git_with_input) + monkeypatch.setattr(archive_performance, "run_git_bytes", fake_run_git_with_input) monkeypatch.setattr(archive_performance, "run_safe_command", fake_run_safe) rc = main( @@ -1763,7 +1778,7 @@ def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> _write_current_benchmark_tooling(worktree) return _result() - def fake_run_git_with_input(args: Sequence[str], input_data: str, cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: + def fake_run_git_with_input(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: calls.append(("git-stdin", tuple(args), cwd)) return _result() @@ -1777,7 +1792,7 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** monkeypatch.chdir(tmp_path) monkeypatch.setattr(archive_performance, "run_git_command", fake_run_git) - monkeypatch.setattr(archive_performance, "run_git_command_with_input", fake_run_git_with_input) + monkeypatch.setattr(archive_performance, "run_git_bytes", fake_run_git_with_input) monkeypatch.setattr(archive_performance, "run_safe_command", fake_run_safe) rc = main( @@ -1828,7 +1843,7 @@ def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> _write_current_benchmark_tooling(worktree) return _result() - def fake_run_git_with_input(args: Sequence[str], input_data: str, cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: + def fake_run_git_with_input(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: calls.append(("git-stdin", tuple(args), cwd)) return _result() @@ -1869,7 +1884,7 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** monkeypatch.chdir(tmp_path) monkeypatch.setattr(archive_performance, "run_git_command", fake_run_git) - monkeypatch.setattr(archive_performance, "run_git_command_with_input", fake_run_git_with_input) + monkeypatch.setattr(archive_performance, "run_git_bytes", fake_run_git_with_input) monkeypatch.setattr(archive_performance, "run_safe_command", fake_run_safe) rc = main( @@ -1939,7 +1954,7 @@ def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> _write_current_benchmark_tooling(worktree) return _result() - def fake_run_git_with_input(args: Sequence[str], input_data: str, cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: + def fake_run_git_with_input(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: calls.append(("git-stdin", tuple(args), cwd)) return _result() @@ -1959,7 +1974,7 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** return _result() monkeypatch.setattr(archive_performance, "run_git_command", fake_run_git) - monkeypatch.setattr(archive_performance, "run_git_command_with_input", fake_run_git_with_input) + monkeypatch.setattr(archive_performance, "run_git_bytes", fake_run_git_with_input) monkeypatch.setattr(archive_performance, "run_safe_command", fake_run_safe) report_id = generate_worktree_report( @@ -2022,7 +2037,7 @@ def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> _write_current_benchmark_tooling(worktree) return _result() - def fake_run_git_with_input(args: Sequence[str], input_data: str, cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: + def fake_run_git_with_input(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: calls.append(("git-stdin", tuple(args), cwd)) return _result() @@ -2044,7 +2059,7 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** return _result() monkeypatch.setattr(archive_performance, "run_git_command", fake_run_git) - monkeypatch.setattr(archive_performance, "run_git_command_with_input", fake_run_git_with_input) + monkeypatch.setattr(archive_performance, "run_git_bytes", fake_run_git_with_input) monkeypatch.setattr(archive_performance, "run_safe_command", fake_run_safe) report_id = generate_worktree_report( @@ -2131,7 +2146,7 @@ def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> _write_current_benchmark_tooling(worktree) return _result() - def fake_run_git_with_input(args: Sequence[str], input_data: str, cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: + def fake_run_git_with_input(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: calls.append(("git-stdin", tuple(args), cwd)) return _result() @@ -2154,7 +2169,7 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** monkeypatch.chdir(tmp_path) monkeypatch.setattr(archive_performance, "run_git_command", fake_run_git) - monkeypatch.setattr(archive_performance, "run_git_command_with_input", fake_run_git_with_input) + monkeypatch.setattr(archive_performance, "run_git_bytes", fake_run_git_with_input) monkeypatch.setattr(archive_performance, "run_safe_command", fake_run_safe) rc = main( @@ -2203,7 +2218,7 @@ def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> _write_current_benchmark_tooling(worktree) return _result() - def fake_run_git_with_input(args: Sequence[str], input_data: str, cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: + def fake_run_git_with_input(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: calls.append(("git-stdin", tuple(args), cwd)) return _result() @@ -2219,7 +2234,7 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** monkeypatch.chdir(tmp_path) monkeypatch.setattr(archive_performance, "run_git_command", fake_run_git) - monkeypatch.setattr(archive_performance, "run_git_command_with_input", fake_run_git_with_input) + monkeypatch.setattr(archive_performance, "run_git_bytes", fake_run_git_with_input) monkeypatch.setattr(archive_performance, "run_safe_command", fake_run_safe) rc = main( @@ -2277,7 +2292,7 @@ def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> _write_current_benchmark_tooling(worktree) return _result() - def fake_run_git_with_input(args: Sequence[str], input_data: str, cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: + def fake_run_git_with_input(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: calls.append(("git-stdin", tuple(args), cwd)) return _result() @@ -2294,7 +2309,7 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** monkeypatch.chdir(tmp_path) monkeypatch.setattr(archive_performance, "run_git_command", fake_run_git) - monkeypatch.setattr(archive_performance, "run_git_command_with_input", fake_run_git_with_input) + monkeypatch.setattr(archive_performance, "run_git_bytes", fake_run_git_with_input) monkeypatch.setattr(archive_performance, "run_safe_command", fake_run_safe) rc = main( @@ -2535,9 +2550,11 @@ def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> Path(output_arg.removeprefix("--output=")).write_bytes(b"diff --git a/README.md b/README.md\n") return _result() - def fake_run_git_with_input(args: Sequence[str], input_data: str | bytes, cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: + def fake_run_git_with_input(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: calls.append(("git-stdin", tuple(args), cwd)) - assert b"diff --git" in input_data if isinstance(input_data, bytes) else "diff --git" in input_data + payload = kwargs["input"] + assert isinstance(payload, bytes) + assert b"diff --git" in payload return _result() def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: @@ -2555,7 +2572,7 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** return _result() monkeypatch.setattr(archive_performance, "run_git_command", fake_run_git) - monkeypatch.setattr(archive_performance, "run_git_command_with_input", fake_run_git_with_input) + monkeypatch.setattr(archive_performance, "run_git_bytes", fake_run_git_with_input) monkeypatch.setattr(archive_performance, "run_safe_command", fake_run_safe) report_id = generate_and_promote_worktree_report( @@ -2596,7 +2613,7 @@ def fake_run_git(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> _write_legacy_benchmark_tooling(worktree) return _result() - def fake_run_git_with_input(args: Sequence[str], input_data: str, cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: + def fake_run_git_with_input(args: Sequence[str], cwd: Path | None = None, **kwargs: Any) -> SimpleNamespace: calls.append(("git-stdin", tuple(args), cwd)) return _result() @@ -2612,7 +2629,7 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** return _result() monkeypatch.setattr(archive_performance, "run_git_command", fake_run_git) - monkeypatch.setattr(archive_performance, "run_git_command_with_input", fake_run_git_with_input) + monkeypatch.setattr(archive_performance, "run_git_bytes", fake_run_git_with_input) monkeypatch.setattr(archive_performance, "run_safe_command", fake_run_safe) report_id = generate_and_promote_worktree_report( diff --git a/scripts/tests/test_cargo_update_integration.py b/scripts/tests/test_cargo_update_integration.py index a686b2b..40ff3f0 100644 --- a/scripts/tests/test_cargo_update_integration.py +++ b/scripts/tests/test_cargo_update_integration.py @@ -15,7 +15,7 @@ import pytest -from subprocess_utils import run_safe_command +from benchmark_process import run_safe_command if TYPE_CHECKING: from collections.abc import Iterator diff --git a/scripts/tests/test_changelog_integration.py b/scripts/tests/test_changelog_integration.py index bb3afd0..d1f774a 100644 --- a/scripts/tests/test_changelog_integration.py +++ b/scripts/tests/test_changelog_integration.py @@ -13,7 +13,7 @@ import pytest from research_repo_tools.cli import main -from subprocess_utils import run_git_command, run_safe_command +from benchmark_process import run_git_command, run_safe_command REPO_ROOT = Path(__file__).resolve().parents[2] diff --git a/scripts/tests/test_justfile_discoverability.py b/scripts/tests/test_justfile_discoverability.py index 926ffec..94f5c41 100644 --- a/scripts/tests/test_justfile_discoverability.py +++ b/scripts/tests/test_justfile_discoverability.py @@ -1,13 +1,17 @@ """Regression tests for the Just recipe surface.""" import json +import re import shlex import shutil import subprocess from pathlib import Path from typing import Any -from subprocess_utils import run_cargo_command, run_safe_command +import yaml +from research_repo_tools.process import run_command + +from benchmark_process import run_safe_command REPO_ROOT = Path(__file__).resolve().parents[2] @@ -41,7 +45,8 @@ def just_recipes() -> dict[str, dict[str, Any]]: def test_exact_benchmark_package_excludes_peer_libraries() -> None: # No dependency resolution: this also runs before Cargo caches exist in CI. metadata = json.loads( - run_cargo_command( + run_command( + "cargo", ["metadata", "--locked", "--offline", "--no-deps", "--format-version", "1"], cwd=REPO_ROOT, ).stdout @@ -99,3 +104,99 @@ def test_ci_enforces_full_python_fixture_lint_policy() -> None: assert "python-fixture-lint" in ci_dependencies assert "ruff check tests/semgrep/scripts/" in fixture_lint_body assert "--select" not in fixture_lint_body + + +def test_bare_just_lists_the_complete_documented_surface_after_source_sorting() -> None: + """Discover new recipes from Just metadata without another maintained help list.""" + recipes = just_recipes() + listed = run_just().stdout + assert listed == run_just("--list").stdout + assert recipes["_default"]["private"] + assert "default" in recipes["_default"]["attributes"] + assert "help-workflows" not in recipes + public = {name: recipe for name, recipe in recipes.items() if not recipe["private"]} + assert set(re.findall(r"^ ([a-z][a-z0-9-]*)", listed, re.MULTILINE)) == set(public) + for name, recipe in public.items(): + assert recipe["doc"], name + line = next(line for line in listed.splitlines() if re.match(rf"^ {re.escape(name)}(?:\s|$)", line)) + for parameter in recipe["parameters"]: + assert parameter["name"] in line + source = (REPO_ROOT / "justfile").read_text(encoding="utf-8") + definitions = re.findall(r"^(?![^\n]*:=)([a-z_][a-z0-9_-]*)(?: [^:\n]*)?:", source, re.MULTILINE) + assert definitions == sorted(definitions) + + +def test_dependabot_shared_caller_covers_every_owned_dependency_file() -> None: + """New manifests or workflows cannot silently fall outside automatic eligibility.""" + workflow = yaml.safe_load((REPO_ROOT / ".github/workflows/dependabot-auto-merge.yml").read_bytes()) + events = workflow.get("on", workflow.get(True)) + assert set(events) == {"pull_request_target"} + assert events["pull_request_target"]["branches"] == ["main"] + assert workflow["permissions"] == {} + assert len(workflow["jobs"]) == 1 + job = workflow["jobs"]["approve-and-enable-auto-merge"] + path, revision = job["uses"].split("@") + assert path == "acgetchell/research-repo-tools/.github/workflows/dependabot-approve.yml" + assert re.fullmatch(r"[0-9a-f]{40}", revision) + assert "steps" not in job + assert "secrets" not in job + assert job["permissions"] == {"contents": "write", "pull-requests": "write"} + assert job["with"]["repository"] == "acgetchell/la-stack" + policy = json.loads(job["with"]["policy"]) + assert set(policy) == {"cargo", "uv", "github_actions"} + assert set(policy["cargo"]["files"]) == {"Cargo.toml", "Cargo.lock", "benches/comparison/Cargo.toml"} + assert set(policy["uv"]["files"]) == {"pyproject.toml", "uv.lock"} + actions = { + path.relative_to(REPO_ROOT).as_posix() + for directory in (REPO_ROOT / ".github/workflows", REPO_ROOT / ".github/actions") + for path in directory.rglob("*") + if path.suffix in {".yml", ".yaml"} + } + assert set(policy["github_actions"]["files"]) == actions + + +def test_zizmor_workflow_separates_findings_from_sarif_export() -> None: + """SARIF's successful exit cannot mask findings in the blocking audit.""" + recipes = just_recipes() + assert "research-repo-tools zizmor check" in json.dumps(recipes["zizmor"]["body"]) + workflow = yaml.safe_load((REPO_ROOT / ".github/workflows/zizmor.yml").read_bytes()) + steps = workflow["jobs"]["analyze"]["steps"] + audit = next(step for step in steps if step.get("id") == "audit") + sarif = next(step for step in steps if step.get("id") == "sarif") + assert audit["run"] == "just zizmor --require-online" + assert "continue-on-error" not in audit + assert "just zizmor --require-online --format sarif" in sarif["run"] + assert "!cancelled()" in sarif["if"] + upload = next(step for step in steps if step.get("uses", "").startswith("github/codeql-action/upload-sarif@")) + assert "steps.sarif.outcome == 'success'" in upload["if"] + assert "github.actor != 'dependabot[bot]'" in upload["if"] + + +def test_semgrep_sarif_upload_honors_only_in_source_suppressions() -> None: + """GitHub receives actionable findings with their original metadata across every run.""" + workflow = yaml.safe_load((REPO_ROOT / ".github/workflows/semgrep-sarif.yml").read_bytes()) + steps = workflow["jobs"]["semgrep-sarif"]["steps"] + scan = next(step for step in steps if step.get("id") == "semgrep") + command = next(line.strip() for line in scan["run"].splitlines() if line.strip().startswith("jq ")) + arguments = shlex.split(command) + actionable = {"ruleId": "actionable", "partialFingerprints": {"primaryLocationLineHash": "original"}} + reviewed = {"ruleId": "reviewed", "suppressions": [{"kind": "inSource"}]} + external = {"ruleId": "external", "suppressions": [{"kind": "external"}]} + other_run = {"ruleId": "other-run", "locations": [{"physicalLocation": {"artifactLocation": {"uri": "other.yml"}}}]} + report = { + "version": "2.1.0", + "runs": [ + {"tool": {"driver": {"name": "Semgrep OSS"}}, "results": [reviewed, actionable, external]}, + {"tool": {"driver": {"name": "Semgrep OSS"}}, "results": [other_run, reviewed]}, + ], + } + filtered = json.loads(run_command("jq", arguments[1:2], input=json.dumps(report)).stdout) + assert filtered["version"] == report["version"] + assert filtered["runs"][0]["results"] == [actionable, external] + assert filtered["runs"][1]["results"] == [other_run] + assert filtered["runs"][0]["tool"] == report["runs"][0]["tool"] + upload = next(step for step in steps if step.get("uses", "").startswith("github/codeql-action/upload-sarif@")) + assert arguments[2:] == ["semgrep-raw.sarif", ">", upload["with"]["sarif_file"]] + gate = next(step for step in steps if step["name"] == "Fail on repository rule findings") + assert gate["if"] == "steps.semgrep.outputs.exit_code != '0'" + assert gate["run"] == "exit 1" diff --git a/scripts/tests/test_maintenance_integration.py b/scripts/tests/test_maintenance_integration.py index 1e7d7d1..e1af1fd 100644 --- a/scripts/tests/test_maintenance_integration.py +++ b/scripts/tests/test_maintenance_integration.py @@ -10,7 +10,7 @@ import pytest from research_repo_tools.cli import main -from subprocess_utils import run_git_command, run_safe_command +from benchmark_process import run_git_command, run_safe_command REPO_ROOT = Path(__file__).resolve().parents[2] @@ -88,8 +88,7 @@ def test_concept_doi_policy_rejects_drift_before_publication(consumer: Path, fil def test_readme_release_selector_requires_the_reviewed_link_inventory(consumer: Path) -> None: path = consumer / "README.md" text = path.read_text(encoding="utf-8") - current = tomllib.loads((consumer / "Cargo.toml").read_text(encoding="utf-8"))["package"]["version"] - path.write_text(text.replace(f"/blob/v{current}/LICENSE", "/blob/main/LICENSE", 1), encoding="utf-8") + path.write_text(text.replace("/blob/main/LICENSE", "/blob/removed/LICENSE", 1), encoding="utf-8") before = snapshot(consumer) assert main(release_args(consumer, "--dry-run")) == 1 assert snapshot(consumer) == before diff --git a/scripts/tests/test_release_baseline.py b/scripts/tests/test_release_baseline.py index 578c857..b8d2268 100644 --- a/scripts/tests/test_release_baseline.py +++ b/scripts/tests/test_release_baseline.py @@ -190,12 +190,13 @@ def host_write_text(path: Path, data: str, encoding: str | None = None, errors: # newline policy must preserve identical manifest bytes on both platforms. return write_text(path, data, encoding=encoding, errors=errors, newline=host_newline if newline is None else newline) - def run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + def run(command: str, args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + assert command == "cargo" calls.append(args) suite = args[args.index("--bench") + 1] return subprocess.CompletedProcess(args, 0, stdout="\n".join(f"{name}: benchmark" for name in IDS[suite])) - monkeypatch.setattr(release_baseline, "run_cargo_command", run) + monkeypatch.setattr(release_baseline, "run_command", run) monkeypatch.setattr(release_baseline, "required_report_ids", lambda: {"d2/la_stack_dot", "exact_d2/det_exact"}) monkeypatch.setattr(Path, "write_text", host_write_text) manifest = tmp_path / "manifest.json" diff --git a/scripts/tests/test_review_integration.py b/scripts/tests/test_review_integration.py index ad34e1b..68d9265 100644 --- a/scripts/tests/test_review_integration.py +++ b/scripts/tests/test_review_integration.py @@ -11,7 +11,7 @@ import pytest from research_repo_tools.cli import main -from subprocess_utils import run_git_command, run_safe_command +from benchmark_process import run_git_command, run_safe_command REPO_ROOT = Path(__file__).resolve().parents[2] @@ -150,7 +150,7 @@ def test_missing_cli_reports_explicit_installation(consumer: Path, monkeypatch: def test_reviews_are_discoverable_and_outside_routine_gates() -> None: surface = json.loads(run_safe_command("just", ["--dump", "--dump-format", "json"], cwd=REPO_ROOT).stdout)["recipes"] - help_text = run_safe_command("just", ["help-workflows"], cwd=REPO_ROOT).stdout + help_text = run_safe_command("just", [], cwd=REPO_ROOT).stdout listed = run_safe_command("just", ["--list"], cwd=REPO_ROOT).stdout for name in ("review", "review-uncommitted"): assert name in help_text diff --git a/scripts/tests/test_run_zizmor.py b/scripts/tests/test_run_zizmor.py deleted file mode 100644 index 8768500..0000000 --- a/scripts/tests/test_run_zizmor.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Exercise authentication selection without network access or real credentials.""" - -import os -import shutil -import stat -import subprocess -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[2] -TOKEN_ENV = ("ZIZMOR_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN") - - -def _shim(path: Path, body: str) -> None: - path.write_text("#!/bin/bash\nset -eu\n" + body, encoding="utf-8", newline="\n") - path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - - -def _run( - tmp_path: Path, - env_overrides: dict[str, str], - *, - expected: str = "", - gh_status: int | None = 0, - scanner_status: int = 0, -) -> subprocess.CompletedProcess[str]: - bash = shutil.which("bash") - assert bash is not None, "Bash is required by the repository's Just recipes" - _shim( - tmp_path / "zizmor", - '[[ "${GH_TOKEN:-}" == "$EXPECTED_TOKEN" ]] || exit 91\n' - '[[ "${ZIZMOR_GITHUB_TOKEN:-}" == "$EXPECTED_TOKEN" ]] || exit 92\n' - 'printf "scanner args: %s\\n" "$*"\n' - 'exit "$SCANNER_STATUS"\n', - ) - if gh_status is not None: - _shim( - tmp_path / "gh", - '[[ "$*" == "auth token" ]] || exit 93\n' - 'echo "called" >> "$GH_CALL_LOG"\n' - 'echo "test-gh-secret"\n' - 'echo "test-gh-diagnostic-secret" >&2\n' - 'exit "$GH_STATUS"\n', - ) - env = { - key: value - for key, value in os.environ.items() - if key not in (*TOKEN_ENV, "ZIZMOR_OFFLINE", "ZIZMOR_NO_ONLINE_AUDITS", "BASH_ENV", "SHELLOPTS", "BASHOPTS") - } - env.update( - EXPECTED_TOKEN=expected, - GH_STATUS=str(gh_status), - GH_CALL_LOG=(tmp_path / "gh-calls").as_posix(), - SCANNER_STATUS=str(scanner_status), - ) - env.update(env_overrides) - # Bash's PWD supplies a POSIX path on Windows too, without drive-letter colons - # being interpreted as PATH separators or reintroducing the real gh binary. - result = subprocess.run( # noqa: S603 - resolved Bash, fixed script, test-owned environment. - [ - bash, - "--noprofile", - "--norc", - "-c", - 'cd "$1"; export PATH="$PWD"; cd "$2"; source scripts/run_zizmor.sh', - "test-zizmor", - tmp_path.as_posix(), - REPO_ROOT.as_posix(), - ], - cwd=REPO_ROOT, - env=env, - capture_output=True, - encoding="utf-8", - check=False, - timeout=10, - ) - for secret in (*(env_overrides.get(key, "") for key in TOKEN_ENV), "test-gh-secret", "test-gh-diagnostic-secret"): - if secret: - assert secret not in result.stdout + result.stderr - return result - - -@pytest.mark.parametrize( - ("tokens", "expected"), - [ - ({"ZIZMOR_GITHUB_TOKEN": "test-zizmor-secret", "GH_TOKEN": "test-gh-env-secret", "GITHUB_TOKEN": "test-github-secret"}, "test-zizmor-secret"), - ({"ZIZMOR_GITHUB_TOKEN": "", "GH_TOKEN": "test-gh-env-secret", "GITHUB_TOKEN": "test-github-secret"}, "test-gh-env-secret"), - ({"GITHUB_TOKEN": "test-github-secret"}, "test-github-secret"), - ], -) -def test_environment_precedence_and_no_token_logging(tmp_path: Path, tokens: dict[str, str], expected: str) -> None: - result = _run(tmp_path, tokens, expected=expected) - - assert result.returncode == 0, result.stderr - assert "authenticated online audits" in result.stdout - assert "scanner args: --persona regular .github" in result.stdout - assert not (tmp_path / "gh-calls").exists() - - -def test_authenticated_gh_fallback(tmp_path: Path) -> None: - result = _run(tmp_path, {}, expected="test-gh-secret") - - assert result.returncode == 0, result.stderr - assert "authenticated online audits" in result.stdout - assert "scanner args: --persona regular .github" in result.stdout - assert (tmp_path / "gh-calls").read_text(encoding="utf-8") == "called\n" - - -@pytest.mark.parametrize("gh_status", [1, None]) -def test_offline_fallback_without_authentication(tmp_path: Path, gh_status: int | None) -> None: - result = _run(tmp_path, {}, gh_status=gh_status) - - assert result.returncode == 0, result.stderr - assert "no GitHub token available; using offline audits" in result.stdout - assert "scanner args: --offline --persona regular .github" in result.stdout - - -@pytest.mark.parametrize("offline_env", ["ZIZMOR_OFFLINE", "ZIZMOR_NO_ONLINE_AUDITS"]) -def test_explicit_offline_skips_credential_lookup(tmp_path: Path, offline_env: str) -> None: - result = _run(tmp_path, {offline_env: "true"}) - - assert result.returncode == 0, result.stderr - assert "offline audits requested" in result.stdout - assert "scanner args: --offline --persona regular .github" in result.stdout - assert not (tmp_path / "gh-calls").exists() - - -@pytest.mark.parametrize("gh_status", [0, 1]) -def test_scanner_failure_propagates_without_offline_retry(tmp_path: Path, gh_status: int) -> None: - result = _run(tmp_path, {}, expected="test-gh-secret" if gh_status == 0 else "", gh_status=gh_status, scanner_status=14) - - assert result.returncode == 14 - assert result.stdout.count("scanner args:") == 1 diff --git a/scripts/tests/test_security_integration.py b/scripts/tests/test_security_integration.py index f9f3bf9..e622beb 100644 --- a/scripts/tests/test_security_integration.py +++ b/scripts/tests/test_security_integration.py @@ -5,7 +5,7 @@ import pytest -from subprocess_utils import run_safe_command +from benchmark_process import run_safe_command REPO_ROOT = Path(__file__).resolve().parents[2] diff --git a/scripts/tests/test_subprocess_utils.py b/scripts/tests/test_subprocess_utils.py deleted file mode 100644 index eef0c87..0000000 --- a/scripts/tests/test_subprocess_utils.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Tests for subprocess_utils.py β€” secure subprocess wrappers.""" - -from typing import TYPE_CHECKING, BinaryIO, cast -from unittest.mock import MagicMock, patch - -import pytest - -import subprocess_utils -from subprocess_utils import ( - DEFAULT_COMMAND_TIMEOUT_SECONDS, - ExecutableNotFoundError, - _build_run_kwargs, - check_git_history, - check_git_repo, - cpu_description, - find_project_root, - format_exception_diagnostics, - get_git_commit_hash, - get_git_remote_url, - get_safe_executable, - run_cargo_command, - run_git_command, - run_git_command_with_input, - run_safe_command, -) - -if TYPE_CHECKING: - from pathlib import Path - -# --------------------------------------------------------------------------- -# get_safe_executable -# --------------------------------------------------------------------------- - - -class TestGetSafeExecutable: - def test_finds_git(self) -> None: - path = get_safe_executable("git") - assert "git" in path - - def test_raises_for_nonexistent_command(self) -> None: - with pytest.raises(ExecutableNotFoundError, match="not found in PATH"): - get_safe_executable("definitely_not_a_real_command_12345") - - -class TestFormatExceptionDiagnostics: - def test_preserves_nested_subprocess_output(self) -> None: - failure = subprocess_utils.subprocess.CalledProcessError( - 128, - ["git", "tag", "v1.2.3"], - output="tag stdout", - stderr="tag rejected by hook", - ) - error = ExceptionGroup("publication and rollback failed", [failure, OSError("rollback target unavailable")]) - - rendered = format_exception_diagnostics(error) - - assert "publication and rollback failed (2 sub-exceptions)" in rendered - assert "git tag v1.2.3" in rendered - assert "tag stdout" in rendered - assert "tag rejected by hook" in rendered - assert "rollback target unavailable" in rendered - - -# --------------------------------------------------------------------------- -# _build_run_kwargs -# --------------------------------------------------------------------------- - - -class TestBuildRunKwargs: - def test_defaults(self) -> None: - kwargs = _build_run_kwargs("test_func") - assert kwargs["capture_output"] is True - assert kwargs["text"] is True - assert kwargs["check"] is True - assert kwargs["encoding"] == "utf-8" - assert kwargs["timeout"] == DEFAULT_COMMAND_TIMEOUT_SECONDS - - def test_rejects_shell_true(self) -> None: - with pytest.raises(ValueError, match="shell=True is not allowed"): - _build_run_kwargs("test_func", shell=True) # noqa: S604 - - def test_rejects_executable_override(self) -> None: - with pytest.raises(ValueError, match="Overriding 'executable' is not allowed"): - _build_run_kwargs("test_func", executable="/bin/sh") - - def test_strips_text_kwarg(self) -> None: - """User-provided text=False is ignored; we always enforce text=True.""" - kwargs = _build_run_kwargs("test_func", text=False) - assert kwargs["text"] is True - - def test_allows_check_false(self) -> None: - kwargs = _build_run_kwargs("test_func", check=False) - assert kwargs["check"] is False - - def test_respects_custom_encoding(self) -> None: - kwargs = _build_run_kwargs("test_func", encoding="latin-1") - assert kwargs["encoding"] == "latin-1" - - def test_respects_custom_timeout(self) -> None: - kwargs = _build_run_kwargs("test_func", timeout=12.5) - assert kwargs["timeout"] == 12.5 - - -# --------------------------------------------------------------------------- -# run_git_command -# --------------------------------------------------------------------------- - - -class TestRunGitCommand: - def test_runs_simple_git_command(self) -> None: - result = run_git_command(["rev-parse", "--git-dir"]) - assert result.returncode == 0 - assert result.stdout.strip() # should output something like ".git" - - def test_raises_on_bad_command(self) -> None: - with pytest.raises(subprocess_utils.subprocess.CalledProcessError): - run_git_command(["not-a-real-git-subcommand"]) - - -# --------------------------------------------------------------------------- -# run_git_command_with_input -# --------------------------------------------------------------------------- - - -class TestRunGitCommandWithInput: - def test_passes_stdin_data(self) -> None: - """Use git hash-object --stdin to verify input piping works.""" - result = run_git_command_with_input( - ["hash-object", "--stdin"], - input_data="hello\n", - ) - # git hash-object of "hello\n" is a well-known SHA - assert result.returncode == 0 - assert result.stdout.strip() == "ce013625030ba8dba906f756967f9e9ca394464a" - - def test_input_data_forwarded_as_raw_utf8(self) -> None: - """Verify stdin bytes preserve LF even when subprocess output is text.""" - observed_input = b"" - - def capture_run(*_args: object, **kwargs: object) -> subprocess_utils.subprocess.CompletedProcess[str]: - nonlocal observed_input - stdin = cast("BinaryIO", kwargs["stdin"]) - observed_input = stdin.read() - return subprocess_utils.subprocess.CompletedProcess(args=["git"], returncode=0, stdout="", stderr="") - - with ( - patch("subprocess_utils.get_safe_executable", return_value="/usr/bin/git") as mock_executable, - patch("subprocess_utils.subprocess.run", side_effect=capture_run) as mock_run, - ): - run_git_command_with_input(["tag", "-a", "v1.0.0", "-F", "-"], input_data="tag body\n") - mock_executable.assert_called_once_with("git") - mock_run.assert_called_once() - _args, kwargs = mock_run.call_args - assert observed_input == b"tag body\n" - assert "input" not in kwargs - assert kwargs["text"] is True - - def test_binary_input_forwarded_without_newline_or_encoding_changes(self) -> None: - observed_input = b"" - - def capture_run(*_args: object, **kwargs: object) -> subprocess_utils.subprocess.CompletedProcess[str]: - nonlocal observed_input - stdin = cast("BinaryIO", kwargs["stdin"]) - observed_input = stdin.read() - return subprocess_utils.subprocess.CompletedProcess(args=["git"], returncode=0, stdout="", stderr="") - - payload = b"line\r\n\x00\xff" - with ( - patch("subprocess_utils.get_safe_executable", return_value="/usr/bin/git"), - patch("subprocess_utils.subprocess.run", side_effect=capture_run), - ): - run_git_command_with_input(["apply", "--binary"], input_data=payload) - - assert observed_input == payload - - -class TestAdditionalHelpers: - def test_cpu_description_uses_macos_brand_and_architecture(self) -> None: - result = subprocess_utils.subprocess.CompletedProcess( - args=["sysctl"], - returncode=0, - stdout="Apple M4 Pro\n", - stderr="", - ) - with ( - patch("subprocess_utils.platform.system", return_value="Darwin"), - patch("subprocess_utils.platform.machine", return_value="arm64"), - patch("subprocess_utils.platform.processor", return_value="arm"), - patch("subprocess_utils.run_safe_command", return_value=result), - ): - assert cpu_description() == "Apple M4 Pro (arm64)" - - def test_run_cargo_command_uses_safe_executable(self) -> None: - with ( - patch("subprocess_utils.get_safe_executable", return_value="/usr/bin/cargo") as mock_executable, - patch("subprocess_utils.subprocess.run") as mock_run, - ): - run_cargo_command(["--version"]) - mock_executable.assert_called_once_with("cargo") - mock_run.assert_called_once() - args, _kwargs = mock_run.call_args - assert args[0] == ["/usr/bin/cargo", "--version"] - - def test_run_safe_command_uses_safe_executable(self) -> None: - with ( - patch("subprocess_utils.get_safe_executable", return_value="/usr/bin/gnuplot") as mock_executable, - patch("subprocess_utils.subprocess.run") as mock_run, - ): - run_safe_command("gnuplot", ["--version"]) - mock_executable.assert_called_once_with("gnuplot") - mock_run.assert_called_once() - args, _kwargs = mock_run.call_args - assert args[0] == ["/usr/bin/gnuplot", "--version"] - - @patch("subprocess_utils.run_git_command") - def test_git_convenience_helpers(self, mock_run_git: MagicMock) -> None: - def fake_run_git(args: list[str], **_kwargs: object) -> subprocess_utils.subprocess.CompletedProcess[str]: - stdout_by_args = { - ("rev-parse", "HEAD"): "abc123def456\n", - ("remote", "get-url", "origin"): "https://github.com/example/repo.git\n", - ("rev-parse", "--git-dir"): ".git\n", - ("log", "--oneline", "-n", "1"): "abc123d message\n", - } - return subprocess_utils.subprocess.CompletedProcess(args=["git", *args], returncode=0, stdout=stdout_by_args[tuple(args)]) - - mock_run_git.side_effect = fake_run_git - - assert get_git_commit_hash() == "abc123def456" - assert get_git_remote_url() == "https://github.com/example/repo.git" - assert check_git_repo() is True - assert check_git_history() is True - assert [call_args.args[0] for call_args in mock_run_git.call_args_list] == [ - ["rev-parse", "HEAD"], - ["remote", "get-url", "origin"], - ["rev-parse", "--git-dir"], - ["log", "--oneline", "-n", "1"], - ] - - def test_find_project_root(self) -> None: - assert (find_project_root() / "Cargo.toml").is_file() - - def test_find_project_root_accepts_a_nested_start(self, tmp_path: Path) -> None: - root = tmp_path / "checkout" - nested = root / "target" / "wheel" - nested.mkdir(parents=True) - (root / "Cargo.toml").write_text("[package]\n", encoding="utf-8") - - assert find_project_root(nested) == root diff --git a/scripts/tests/test_toolchain_integration.py b/scripts/tests/test_toolchain_integration.py index f75c936..ebb2ee1 100644 --- a/scripts/tests/test_toolchain_integration.py +++ b/scripts/tests/test_toolchain_integration.py @@ -13,7 +13,7 @@ import pytest from research_repo_tools.cli import main -from subprocess_utils import run_safe_command +from benchmark_process import run_safe_command if TYPE_CHECKING: import subprocess diff --git a/semgrep.yaml b/semgrep.yaml index f781be5..65e2844 100644 --- a/semgrep.yaml +++ b/semgrep.yaml @@ -395,7 +395,8 @@ rules: - "/tests/semgrep/.github/workflows/**/*.yml" - "/tests/semgrep/.github/workflows/**/*.yaml" patterns: - - pattern-regex: '(?m)^\s*uses:\s*(?!\./)(?!docker://)[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?@(?![a-fA-F0-9]{40}(?:\s+#|$))[^\s#]+' + - pattern-regex: >- + (?m)^[ \t]*(?:-[ \t]*)?uses:\s*(?!\./)(?!docker://)[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*@(?![a-fA-F0-9]{40}(?:\s+#|$))[^\s#]+ - id: la-stack.github-actions.external-action-approved-allowlist languages: @@ -412,7 +413,7 @@ rules: - "/tests/semgrep/.github/workflows/**/*.yml" - "/tests/semgrep/.github/workflows/**/*.yaml" patterns: - - pattern-regex: '(?m)^\s*uses:\s*(?!\./)(?!docker://)(?!(?:actions/checkout|actions/cache|actions/download-artifact|actions/github-script|actions/setup-python|actions/upload-artifact|actions-rust-lang/setup-rust-toolchain|astral-sh/setup-uv|codecov/codecov-action|github/codeql-action/(?:upload-sarif|init|analyze)|swatinem/rust-cache|taiki-e/cache-cargo-install-action|zizmorcore/zizmor-action)@)[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?@' # yamllint disable-line rule:line-length + - pattern-regex: '(?m)^[ \t]*(?:-[ \t]*)?uses:\s*(?!\./)(?!docker://)(?!(?:actions/checkout|actions/cache|actions/download-artifact|actions/github-script|actions/setup-python|actions/upload-artifact|actions-rust-lang/setup-rust-toolchain|astral-sh/setup-uv|codecov/codecov-action|github/codeql-action/(?:upload-sarif|init|analyze)|swatinem/rust-cache|taiki-e/cache-cargo-install-action|zizmorcore/zizmor-action|acgetchell/research-repo-tools/\.github/workflows/dependabot-approve\.yml)@)[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*@' # yamllint disable-line rule:line-length - id: la-stack.github-actions.external-action-version-comment languages: @@ -429,7 +430,7 @@ rules: - "/tests/semgrep/.github/workflows/**/*.yml" - "/tests/semgrep/.github/workflows/**/*.yaml" patterns: - - pattern-regex: '(?m)^\s*uses:\s*(?!\./)(?!docker://)[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?@[a-fA-F0-9]{40}\s*$' + - pattern-regex: '(?m)^[ \t]*(?:-[ \t]*)?uses:\s*(?!\./)(?!docker://)[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*@[a-fA-F0-9]{40}\s*$' - id: la-stack.github-actions.zizmor-tool-version-pinned languages: diff --git a/tests/semgrep/.github/workflows/action_policy.yml b/tests/semgrep/.github/workflows/action_policy.yml index 90d0e20..6b82528 100644 --- a/tests/semgrep/.github/workflows/action_policy.yml +++ b/tests/semgrep/.github/workflows/action_policy.yml @@ -5,6 +5,29 @@ on: pull_request_target: jobs: + approved-dependency-review: + # ok: la-stack.github-actions.external-action-approved-allowlist, la-stack.github-actions.external-action-sha-pinned + uses: acgetchell/research-repo-tools/.github/workflows/dependabot-approve.yml@cbb2ea6dee8866b3f0547bca935aef48fdd71707 # reviewed 2026-10-01 + unpinned-dependency-review: + # ruleid: la-stack.github-actions.external-action-sha-pinned + uses: acgetchell/research-repo-tools/.github/workflows/dependabot-approve.yml@main + unknown-dependency-review: + # ruleid: la-stack.github-actions.external-action-approved-allowlist + uses: acgetchell/research-repo-tools/.github/workflows/unapproved.yml@cbb2ea6dee8866b3f0547bca935aef48fdd71707 # reviewed 2026-10-01 + uncommented-dependency-review: + # ruleid: la-stack.github-actions.external-action-version-comment + uses: acgetchell/research-repo-tools/.github/workflows/dependabot-approve.yml@cbb2ea6dee8866b3f0547bca935aef48fdd71707 + action-shorthand: + runs-on: ubuntu-latest + steps: + # ok: la-stack.github-actions.external-action-approved-allowlist, la-stack.github-actions.external-action-sha-pinned + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + # ruleid: la-stack.github-actions.external-action-sha-pinned + - uses: actions/cache@v6 + # ruleid: la-stack.github-actions.external-action-approved-allowlist + - uses: unapproved/example-action@1111111111111111111111111111111111111111 # v1.0.0 + # ruleid: la-stack.github-actions.external-action-version-comment + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 fixtures: runs-on: ubuntu-latest steps: diff --git a/uv.lock b/uv.lock index 73d4de3..b9fafba 100644 --- a/uv.lock +++ b/uv.lock @@ -472,6 +472,9 @@ wheels = [ name = "la-stack-scripts" version = "0.4.6" source = { editable = "." } +dependencies = [ + { name = "research-repo-tools" }, +] [package.dev-dependencies] dev = [ @@ -490,6 +493,7 @@ tooling = [ ] [package.metadata] +requires-dist = [{ name = "research-repo-tools", specifier = "==0.1.7" }] [package.metadata.requires-dev] dev = [