diff --git a/.github/actions/prepare-release-benchmarks/action.yml b/.github/actions/prepare-release-benchmarks/action.yml index aff6226..a5d6260 100644 --- a/.github/actions/prepare-release-benchmarks/action.yml +++ b/.github/actions/prepare-release-benchmarks/action.yml @@ -20,49 +20,10 @@ runs: exit 1 fi - - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + - name: Set up declared tools without caches + uses: ./.github/actions/setup-tools # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... with: cache: false - cache-bin: false - - - name: Set up just - uses: ./.github/actions/setup-just # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... - with: - cache: false - - - name: Resolve tool versions - id: tool_versions - shell: bash - run: | - set -euo pipefail - - version="$(just --evaluate cargo_nextest_version)" - uv_version="$(just --evaluate uv_version)" - if [[ -z "$version" || -z "$uv_version" ]]; then - echo "::error::Could not resolve pinned tool versions from justfile" - exit 1 - fi - - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "uv_version=$uv_version" >> "$GITHUB_OUTPUT" - - - name: Install cargo-nextest - shell: bash - env: - CARGO_NEXTEST_VERSION: ${{ steps.tool_versions.outputs.version }} - run: cargo install --locked cargo-nextest --version "$CARGO_NEXTEST_VERSION" - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version-file: ".python-version" - - - name: Install uv - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - with: - version: ${{ steps.tool_versions.outputs.uv_version }} - enable-cache: false - name: Validate benchmark inputs shell: bash diff --git a/.github/actions/setup-just/action.yml b/.github/actions/setup-just/action.yml deleted file mode 100644 index 4f1300c..0000000 --- a/.github/actions/setup-just/action.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Set up just -description: Resolve the pinned just version from justfile and install it. - -inputs: - cache: - description: Restore and save the installed just binary in the Actions cache. - required: false - default: "true" - -outputs: - version: - description: Resolved just version from justfile. - value: ${{ steps.resolve.outputs.version }} - -runs: - using: composite - steps: - - name: Resolve just version - id: resolve - shell: bash - run: | - set -euo pipefail - shopt -s extglob - - version="" - declaration_re='^[[:space:]]*just_version[[:space:]]*:=[[:space:]]*(.*)$' - while IFS= read -r line; do - if [[ "$line" =~ $declaration_re ]]; then - value="${BASH_REMATCH[1]}" - value="${value%%#*}" - value="${value##+([[:space:]])}" - value="${value%%+([[:space:]])}" - - if [[ ${#value} -ge 2 ]]; then - first="${value:0:1}" - last="${value: -1}" - if [[ "$first" == "$last" && ( "$first" == '"' || "$first" == "'" ) ]]; then - value="${value:1:${#value}-2}" - fi - fi - - version="$value" - break - fi - done < justfile - - if [[ -z "$version" ]]; then - echo "::error::Could not resolve just_version from justfile" - exit 1 - fi - - echo "version=$version" >> "$GITHUB_OUTPUT" - - - name: Install just with caching - if: inputs.cache == 'true' - uses: taiki-e/cache-cargo-install-action@9ee83daaa7b96a6fab930949ecf1122bba04a389 # v3.0.8 - with: - tool: just@${{ steps.resolve.outputs.version }} - - - name: Install just without caching - if: inputs.cache != 'true' - shell: bash - env: - JUST_VERSION: ${{ steps.resolve.outputs.version }} - run: cargo install --locked just --version "$JUST_VERSION" diff --git a/.github/actions/setup-tools/action.yml b/.github/actions/setup-tools/action.yml new file mode 100644 index 0000000..c54b5e6 --- /dev/null +++ b/.github/actions/setup-tools/action.yml @@ -0,0 +1,38 @@ +name: Set up declared tools +description: Install the locked shared package, synchronize declared tools, and export verified paths. + +inputs: + cache: + description: Restore and save managed tool installations; release jobs must disable this. + required: false + default: "true" + +runs: + using: composite + steps: + - name: Install declared uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + version-file: pyproject.toml + enable-cache: ${{ inputs.cache }} + cache-python: false + + - name: Cache declared Rust and Cargo tools + if: inputs.cache == 'true' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/rrt + key: rrt-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('rust-toolchain.toml', 'pyproject.toml', 'uv.lock', '.python-version') }} + restore-keys: rrt-${{ runner.os }}-${{ runner.arch }}- + + - name: Set up declared tools and development environment + shell: bash + env: + RESEARCH_REPO_TOOLS_HOME: ${{ runner.temp }}/rrt + run: uv run --locked --managed-python --only-group tooling research-repo-tools setup + + - name: Export verified managed paths + shell: bash + env: + RESEARCH_REPO_TOOLS_HOME: ${{ runner.temp }}/rrt + run: uv run --locked --no-sync --no-python-downloads research-repo-tools toolchain export diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index a8d68ad..9ff573c 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -22,7 +22,10 @@ on: - "Cargo.lock" - "justfile" - "rust-toolchain.toml" - - ".github/actions/setup-just/action.yml" + - "pyproject.toml" + - "uv.lock" + - ".python-version" + - ".github/actions/setup-tools/action.yml" - ".github/workflows/benchmarks.yml" pull_request: branches: @@ -36,7 +39,10 @@ on: - "Cargo.lock" - "justfile" - "rust-toolchain.toml" - - ".github/actions/setup-just/action.yml" + - "pyproject.toml" + - "uv.lock" + - ".python-version" + - ".github/actions/setup-tools/action.yml" - ".github/workflows/benchmarks.yml" workflow_dispatch: @@ -65,33 +71,8 @@ jobs: with: persist-credentials: false - - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - with: - cache: true - cache-bin: false - - - name: Set up just - uses: ./.github/actions/setup-just # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... - - - name: Resolve cargo-nextest version - id: cargo_nextest_version - shell: bash - run: | - set -euo pipefail - - version="$(just --evaluate cargo_nextest_version)" - if [[ -z "$version" ]]; then - echo "::error::Could not resolve cargo_nextest_version from justfile" - exit 1 - fi - - echo "version=$version" >> "$GITHUB_OUTPUT" - - - name: Install cargo-nextest - uses: taiki-e/cache-cargo-install-action@9ee83daaa7b96a6fab930949ecf1122bba04a389 # v3.0.8 - with: - tool: cargo-nextest@${{ steps.cargo_nextest_version.outputs.version }} + - name: Set up declared tools + uses: ./.github/actions/setup-tools # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... - name: Validate benchmark inputs run: just test-bench-inputs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1670128..b1235bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,13 +35,6 @@ jobs: - ubuntu-latest - macos-latest - windows-latest - include: - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - - os: macos-latest - target: x86_64-apple-darwin - - os: windows-latest - target: x86_64-pc-windows-msvc steps: - name: Disable Git autocrlf on Windows @@ -55,123 +48,8 @@ jobs: with: persist-credentials: false - - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - with: - target: ${{ matrix.target }} - cache: true - cache-bin: false - # toolchain, components, etc. are specified in rust-toolchain.toml - - - name: Set up just - uses: ./.github/actions/setup-just # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... - - - name: Export tool versions - id: tool_versions - shell: bash - run: | - set -euo pipefail - - resolve_version() { - local name="$1" - local value - - if ! value="$(just --evaluate "$name")"; then - echo "::error::Failed to resolve $name from justfile" >&2 - return 1 - fi - if [[ -z "$value" ]]; then - echo "::error::Resolved empty $name from justfile" >&2 - return 1 - fi - - printf '%s\n' "$value" - } - - cargo_machete_version="$(resolve_version cargo_machete_version)" - cargo_nextest_version="$(resolve_version cargo_nextest_version)" - dprint_version="$(resolve_version dprint_version)" - git_cliff_version="$(resolve_version git_cliff_version)" - rumdl_version="$(resolve_version rumdl_version)" - taplo_version="$(resolve_version taplo_version)" - typos_version="$(resolve_version typos_version)" - uv_version="$(resolve_version uv_version)" - zizmor_version="$(resolve_version zizmor_version)" - - { - echo "CARGO_MACHETE_VERSION=$cargo_machete_version" - echo "CARGO_NEXTEST_VERSION=$cargo_nextest_version" - echo "DPRINT_VERSION=$dprint_version" - echo "GIT_CLIFF_VERSION=$git_cliff_version" - echo "RUMDL_VERSION=$rumdl_version" - echo "TAPLO_VERSION=$taplo_version" - echo "TYPOS_VERSION=$typos_version" - echo "UV_VERSION=$uv_version" - echo "ZIZMOR_VERSION=$zizmor_version" - } >> "$GITHUB_OUTPUT" - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version-file: ".python-version" - - - name: Install uv - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - with: - version: ${{ steps.tool_versions.outputs.UV_VERSION }} - enable-cache: true - - - name: Sync Python tooling - run: uv sync --locked --group dev - - - name: Install dprint - uses: taiki-e/cache-cargo-install-action@9ee83daaa7b96a6fab930949ecf1122bba04a389 # v3.0.8 - with: - tool: dprint@${{ steps.tool_versions.outputs.DPRINT_VERSION }} - - - name: Install rumdl - uses: taiki-e/cache-cargo-install-action@9ee83daaa7b96a6fab930949ecf1122bba04a389 # v3.0.8 - with: - tool: rumdl@${{ steps.tool_versions.outputs.RUMDL_VERSION }} - - - name: Install taplo - id: install-taplo - continue-on-error: ${{ matrix.os == 'windows-latest' }} - uses: taiki-e/cache-cargo-install-action@9ee83daaa7b96a6fab930949ecf1122bba04a389 # v3.0.8 - with: - tool: taplo-cli@${{ steps.tool_versions.outputs.TAPLO_VERSION }} - - - name: Install taplo on Windows after cached install failure - if: matrix.os == 'windows-latest' && steps.install-taplo.outcome == 'failure' - shell: pwsh - run: cargo install --locked taplo-cli --version $env:TAPLO_VERSION - env: - TAPLO_VERSION: ${{ steps.tool_versions.outputs.TAPLO_VERSION }} - - - name: Install typos - uses: taiki-e/cache-cargo-install-action@9ee83daaa7b96a6fab930949ecf1122bba04a389 # v3.0.8 - with: - tool: typos-cli@${{ steps.tool_versions.outputs.TYPOS_VERSION }} - - - name: Install zizmor - uses: taiki-e/cache-cargo-install-action@9ee83daaa7b96a6fab930949ecf1122bba04a389 # v3.0.8 - with: - tool: zizmor@${{ steps.tool_versions.outputs.ZIZMOR_VERSION }} - - - name: Install cargo-machete - uses: taiki-e/cache-cargo-install-action@9ee83daaa7b96a6fab930949ecf1122bba04a389 # v3.0.8 - with: - tool: cargo-machete@${{ steps.tool_versions.outputs.CARGO_MACHETE_VERSION }} - - - name: Install cargo-nextest - uses: taiki-e/cache-cargo-install-action@9ee83daaa7b96a6fab930949ecf1122bba04a389 # v3.0.8 - with: - tool: cargo-nextest@${{ steps.tool_versions.outputs.CARGO_NEXTEST_VERSION }} - - - name: Install git-cliff - uses: taiki-e/cache-cargo-install-action@9ee83daaa7b96a6fab930949ecf1122bba04a389 # v3.0.8 - with: - tool: git-cliff@${{ steps.tool_versions.outputs.GIT_CLIFF_VERSION }} + - name: Set up declared tools + uses: ./.github/actions/setup-tools # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... - name: Run CI checks run: just ci diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index d0b3033..f39890f 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -25,57 +25,8 @@ jobs: fetch-depth: 0 # Needed for Codecov diff analysis persist-credentials: false - - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - with: - cache: true # toolchain/components are specified in rust-toolchain.toml - cache-bin: false - - - name: Set up just - uses: ./.github/actions/setup-just # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... - - - name: Export coverage tool versions - id: tool_versions - shell: bash - run: | - set -euo pipefail - - resolve_version() { - local name="$1" - local value - - if ! value="$(just --evaluate "$name")"; then - echo "::error::Failed to resolve $name from justfile" >&2 - return 1 - fi - if [[ -z "$value" ]]; then - echo "::error::Resolved empty $name from justfile" >&2 - return 1 - fi - - printf '%s\n' "$value" - } - - cargo_llvm_cov_version="$(resolve_version cargo_llvm_cov_version)" - cargo_nextest_version="$(resolve_version cargo_nextest_version)" - - { - echo "CARGO_LLVM_COV_VERSION=$cargo_llvm_cov_version" - echo "CARGO_NEXTEST_VERSION=$cargo_nextest_version" - } >> "$GITHUB_OUTPUT" - - - name: Install LLVM coverage tools - run: rustup component add llvm-tools-preview - - - name: Install cargo-llvm-cov - uses: taiki-e/cache-cargo-install-action@9ee83daaa7b96a6fab930949ecf1122bba04a389 # v3.0.8 - with: - tool: cargo-llvm-cov@${{ steps.tool_versions.outputs.CARGO_LLVM_COV_VERSION }} - - - name: Install cargo-nextest - uses: taiki-e/cache-cargo-install-action@9ee83daaa7b96a6fab930949ecf1122bba04a389 # v3.0.8 - with: - tool: cargo-nextest@${{ steps.tool_versions.outputs.CARGO_NEXTEST_VERSION }} + - name: Set up declared tools + uses: ./.github/actions/setup-tools # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... - name: Run coverage run: | diff --git a/.github/workflows/rust-clippy.yml b/.github/workflows/rust-clippy.yml index 2e5de6b..d7e08f7 100644 --- a/.github/workflows/rust-clippy.yml +++ b/.github/workflows/rust-clippy.yml @@ -34,54 +34,8 @@ jobs: with: persist-credentials: false - - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - with: - cache: true # toolchain/components are specified in rust-toolchain.toml - cache-bin: false - - - name: Set up just - uses: ./.github/actions/setup-just # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... - - - name: Export tool versions - id: tool_versions - shell: bash - run: | - set -euo pipefail - - resolve_version() { - local name="$1" - local value - - if ! value="$(just --evaluate "$name")"; then - echo "::error::Failed to resolve $name from justfile" >&2 - return 1 - fi - if [[ -z "$value" ]]; then - echo "::error::Resolved empty $name from justfile" >&2 - return 1 - fi - - printf '%s\n' "$value" - } - - clippy_sarif_version="$(resolve_version clippy_sarif_version)" - sarif_fmt_version="$(resolve_version sarif_fmt_version)" - - { - echo "CLIPPY_SARIF_VERSION=$clippy_sarif_version" - echo "SARIF_FMT_VERSION=$sarif_fmt_version" - } >> "$GITHUB_OUTPUT" - - - name: Install clippy-sarif - uses: taiki-e/cache-cargo-install-action@9ee83daaa7b96a6fab930949ecf1122bba04a389 # v3.0.8 - with: - tool: clippy-sarif@${{ steps.tool_versions.outputs.CLIPPY_SARIF_VERSION }} - - - name: Install sarif-fmt - uses: taiki-e/cache-cargo-install-action@9ee83daaa7b96a6fab930949ecf1122bba04a389 # v3.0.8 - with: - tool: sarif-fmt@${{ steps.tool_versions.outputs.SARIF_FMT_VERSION }} + - name: Set up declared tools + uses: ./.github/actions/setup-tools # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... - name: Run clippy with SARIF output run: | diff --git a/.github/workflows/semgrep-sarif.yml b/.github/workflows/semgrep-sarif.yml index 9ce4198..a678fe3 100644 --- a/.github/workflows/semgrep-sarif.yml +++ b/.github/workflows/semgrep-sarif.yml @@ -27,34 +27,15 @@ jobs: semgrep-sarif: name: Repository Rule SARIF Analysis runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 60 steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Set up just - uses: ./.github/actions/setup-just # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... - - - name: Resolve uv version - id: uv_version - shell: bash - run: | - set -euo pipefail - - version="$(just --evaluate uv_version)" - if [[ -z "$version" ]]; then - echo "::error::Could not resolve uv_version from justfile" - exit 1 - fi - - echo "version=$version" >> "$GITHUB_OUTPUT" - - - name: Install uv - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - with: - version: ${{ steps.uv_version.outputs.version }} + - name: Set up declared tools + uses: ./.github/actions/setup-tools # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... - name: Run repository Semgrep rules id: semgrep diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 2a69414..c48899b 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -32,27 +32,27 @@ jobs: with: persist-credentials: false - - name: Set up just - uses: ./.github/actions/setup-just # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... + - name: Set up declared tools + uses: ./.github/actions/setup-tools # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... - - name: Resolve zizmor version - id: zizmor_version - shell: bash + - name: Run declared zizmor with SARIF output + env: + GH_TOKEN: ${{ github.token }} run: | - set -euo pipefail - - version="$(just --evaluate zizmor_version)" - if [[ -z "$version" ]]; then - echo "::error::Could not resolve zizmor_version from justfile" - exit 1 + 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" - echo "version=$version" >> "$GITHUB_OUTPUT" - - - name: Run zizmor - uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 + - 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) + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: - inputs: .github - online-audits: true - persona: regular - version: ${{ steps.zizmor_version.outputs.version }} + sarif_file: zizmor-results.sarif + category: zizmor + wait-for-processing: true diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..7372c3b --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,14 @@ +[extend] +useDefault = true + +# Commit d8f9897 contains a historical identifier alias, not a credential. +# Match only that assignment in its owning file; retain all default detectors. +[[rules]] +id = "generic-api-key" + +[[rules.allowlists]] +description = "Historical benchmark compatibility identifier alias" +condition = "AND" +paths = [ '''(?:^|[/\\])scripts[/\\]bench_compare\.py$''' ] +regexTarget = "line" +regexes = [ '''^\s*_V0_4_3_API_COMPATIBILITY\s*=\s*V0_4_3_API_COMPATIBILITY\s*$''' ] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff0bc73..41590be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,14 +6,13 @@ clarity, and the fixed-dimension stack-allocation model. ## Getting Started -Install Rust 1.98.1 through [rustup](https://rustup.rs/), Git, the -[GitHub CLI](https://cli.github.com/), Python 3.14, -[`uv` 0.12.10](https://docs.astral.sh/uv/), and `jq`. Authenticate the GitHub -CLI for repository operations, then install the repository's pinned `just` -version from its locked dependency graph: +Install Git, the +[GitHub CLI](https://cli.github.com/), [`uv`](https://docs.astral.sh/uv/) at the +exact version declared in `pyproject.toml`, and `jq`. Authenticate the GitHub CLI for repository operations. Bootstrap +the declared Python, Rust, Cargo tools, and Just through the locked PyPI package: ```bash -cargo install --locked just --version 1.58.0 +uv run --locked --managed-python --only-group tooling research-repo-tools setup ``` Set up the remaining development tools and validate the checkout: @@ -27,7 +26,7 @@ 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. -Changelog and opt-in CodeRabbit review commands use the published `research-repo-tools==0.1.7` package, +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 and CI install it from PyPI through `uv sync --locked --group dev`; a sibling checkout is unnecessary. To upgrade it deliberately, review the exact @@ -35,19 +34,37 @@ checkout is unnecessary. To upgrade it deliberately, review the exact integration tests and `just ci`. See the [Scripts guide](scripts/README.md#changelog-and-release-tooling) for the retained changelog policy and ownership boundary. -This adoption leaves the existing setup and dependency-update -recipes in place. The shared setup contract requires an existing uv and uses -`research-repo-tools setup`; it does not generate bootstrap installers. -Full toolchain and release-metadata adoption are separate follow-ups. - -Use `just update` for deliberate dependency and tool maintenance. It composes -`just update-dependencies`, which advances Cargo dependency requirements, exact -Python development-tool pins, and the Cargo/uv locks, with -`just update-cargo-tools`, which upgrades only the Cargo CLI packages owned by -`setup-tools` and atomically reconciles their root `justfile` pins. `just setup` -installs and verifies the pinned `cargo-update` package that provides -`cargo-install-update`; the updater does not touch unrelated Cargo executables -or uv's user-global tool environments. +The shared setup contract requires an existing uv and does not generate bootstrap +installers. `.python-version` selects Python; `rust-toolchain.toml` selects Rust +and its components; `pyproject.toml` owns the exact uv requirement and managed +Cargo tool versions. `just setup-tools` installs these declarations and explicitly +synchronizes `dev`, even with `default-groups = []`. `just tools-check` verifies +without installing. Recipes execute managed tools through the checked runner; +an unrelated system installation cannot satisfy a managed Cargo pin. + +Use `just update` for deliberate maintenance. It runs `just update-tools` first: +upgrade uv through its installation owner, upgrade declared managed Cargo tools, +publish their verified pins, and synchronize setup. Just follows the shared +package's `rust-just` pin. Managed upgrades replace `cargo-update` and the old +Just-variable reconciler; unrelated user-global Cargo installations stay outside +this workflow. + +Then `just update-dependencies` upgrades Cargo requirements and refreshes Cargo's +lock, resolves exact direct Python `dev` pins, refreshes the complete `uv.lock`, +and explicitly synchronizes `dev` with managed Rust available for native builds. +Included groups retain their constraints, including the exact shared-package pin. +`just update-cargo-dependencies` excludes `num-bigint` and `num-rational` from +incompatible requirement upgrades because their public types must advance +together; Cargo can still refresh locked versions within those requirements. +`just update-python-dependencies` and its `update-python-deps` alias run the full +Python update and sync sequence. Dependency-only commands preserve tool pins; +tool-only commands preserve dependency requirements and locks. A failure stops +later steps, without rolling back earlier package-manager updates. + +Release metadata, version checks, Markdown line checks, and Semgrep fixture +validation also use the shared CLI. Consumer integration tests exercise the +actual release policy and native dependency updates; common helper regressions +belong to research-repo-tools. The repository uses `cargo-nextest` for runnable Rust tests, `cargo-machete` for unused-dependency checks, and `just cargo-lock-check` to verify that the @@ -63,8 +80,17 @@ workflow. It runs online audits using `ZIZMOR_GITHUB_TOKEN`, `GH_TOKEN`, 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. -Zizmor owns remote action SHA/tag resolution; Semgrep guards explicit scanner -version configuration and cache isolation in release workflows. +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. + +`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 +`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 +run separately from `just ci`; the hosted RustSec audit remains in place. CI runs `just ci` on Ubuntu, macOS, and Windows to keep platform coverage aligned with the local comprehensive validation path. diff --git a/README.md b/README.md index 7adfbf1..9a0a8b8 100644 --- a/README.md +++ b/README.md @@ -460,13 +460,13 @@ expectations are validated outside the timed closures. A short contributor workflow: -Install Rust 1.98.1 through [rustup](https://rustup.rs/), Git, -[GitHub CLI](https://cli.github.com/), Python 3.14, -[`uv` 0.12.5](https://docs.astral.sh/uv/), and `jq`. Then install the pinned -`just` release from its locked dependency graph: +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 -cargo install --locked just --version 1.58.0 +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) diff --git a/SECURITY.md b/SECURITY.md index b56485a..efa8845 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -102,6 +102,28 @@ This project uses GitHub CodeQL, Dependabot security updates, secret scanning with push protection, `cargo audit`, zizmor, Clippy SARIF analysis, and repository-owned Semgrep rules. +`just security` adds shared OSV dependency auditing and Gitleaks scans of reachable +Git history and current tracked/nonignored files. Both use declared managed +binaries installed by shared setup and retain redacted JSON/SARIF reports under +`target/security`. See the [contributor workflow](CONTRIBUTING.md#getting-started) +for the separate scan commands and prerequisites. + +### Historical identifier false positive + +Gitleaks flags a historical compatibility alias in `scripts/bench_compare.py` +at commit `d8f9897f59cce0a220efdbbcbaaf9180861889e9`. The alias assigns one Python +identifier to another; the assignment contains no credential. +`.gitleaks.toml` retains the default detectors and permits only this exact +assignment in that file. Other assignments and paths remain checked. + +### Python tool dependencies + +Semgrep 1.178.0 requires `pyjwt[crypto]~=2.13.0`, which retains packages affected +by the PyJWT advisories reported by OSV. The uv override in `pyproject.toml` +preserves the crypto extra and selects `pyjwt>=2.15.1,<3`; `uv.lock` records the +resolved version. Remove the override when the pinned Semgrep release accepts +the patched PyJWT versions. Validate native Semgrep scans after either change. + ### Numerical logging false positives CodeQL's `rust/cleartext-logging` query uses name-based heuristics to identify diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md index 7e8ff79..3a1df0c 100644 --- a/docs/BENCHMARKING.md +++ b/docs/BENCHMARKING.md @@ -24,18 +24,23 @@ the commands measure and where their outputs go. ## Start Here +For explicit release comparisons, set `CURRENT_TAG` and `PREVIOUS_TAG` to the +stable tags you want to compare. Omit both arguments to use each recipe's +documented defaults. These variables keep command examples independent of the +next release; measured artifact links and historical reports retain their tags. + | Goal | Recipe | |------|--------| | Latest-release local audit | `just performance-local` | -| Non-exact release-signal check against tags | `just performance-local-non-exact v0.4.6 v0.4.5` | +| Non-exact release-signal check against tags | `just performance-local-non-exact "$CURRENT_TAG" "$PREVIOUS_TAG"` | | Fast saved-baseline loop | `just bench-save-baseline ` then `just bench-compare all-benches` | | Full crate comparison | `just bench-vs-linalg` | | Interval determinant filter | `just bench-interval` | | Certified dot/linear-form filter | `just bench-linear-form` | | README table and plot | `just performance-release` then `just performance-readme` | -| Release report | `just performance-release v0.4.6 v0.4.5` | +| Release report | `just performance-release "$CURRENT_TAG" "$PREVIOUS_TAG"` | | Build docs from retained release inputs | `just performance-doc` | -| Published-asset comparison | `just performance-github-assets v0.4.6 v0.4.5` | +| Published-asset comparison | `just performance-github-assets "$CURRENT_TAG" "$PREVIOUS_TAG"` | Rule of thumb: @@ -193,10 +198,10 @@ distinct release identifiers. For a narrower non-exact check against a known release pair, run: ```bash -just performance-local-non-exact v0.4.6 v0.4.5 +just performance-local-non-exact "$CURRENT_TAG" "$PREVIOUS_TAG" ``` -This generates a local `v0.4.5` `vs_linalg` baseline, measures the current +This generates a local `$PREVIOUS_TAG` `vs_linalg` baseline, measures the current la-stack `vs_linalg` rows, and renders a `vs_linalg` report. The report includes saved baseline nalgebra/faer timings as context where matching peer rows exist, without rerunning current peer crates. @@ -296,7 +301,7 @@ Release PRs promote one curated release-to-release comparison into committed docs: ```bash -just performance-release v0.4.6 v0.4.5 +just performance-release "$CURRENT_TAG" "$PREVIOUS_TAG" ``` With no arguments, `just performance-release` infers the current release tag @@ -367,7 +372,7 @@ requirement applies even when both release tags are supplied explicitly because the recipe still downloads their GitHub Release assets: ```bash -just performance-github-assets v0.4.6 v0.4.5 +just performance-github-assets "$CURRENT_TAG" "$PREVIOUS_TAG" ``` With no arguments, the recipe discovers the latest and previous stable diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 43f8004..e30f211 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -35,9 +35,8 @@ git switch main git pull --ff-only ``` -Install or verify the pinned development tools before running maintenance -recipes. This includes the `cargo-update` package that provides -`cargo-install-update` for `just update`: +Install or verify the declared development tools before running maintenance +recipes. Shared setup installs managed tools and explicitly synchronizes `dev`: ```bash just setup @@ -308,9 +307,9 @@ The command must print `la-stack-$TAG-criterion-baseline.tar.gz`. A short-lived Actions artifact is not a substitute for this release asset. The benchmark producer has read-only repository permissions and restores no -dependency caches, including tool binaries. It disables Rust toolchain and -`setup-just` caching and installs the pinned just and cargo-nextest versions -with `cargo install --locked`. The short preflight job receives `contents: write` +dependency caches, including tool binaries. It invokes `setup-tools` with +`cache: false`, installs declared managed Rust and Cargo tools through the locked +shared package, and exports verified paths. The short preflight job receives `contents: write` for draft visibility; the separate publisher receives it to attach the archive and publish. Neither privileged job checks out or executes repository code. diff --git a/docs/code_organization.md b/docs/code_organization.md index a975a5a..40df6f3 100644 --- a/docs/code_organization.md +++ b/docs/code_organization.md @@ -107,8 +107,8 @@ inherit the root workspace lints and share the lockfile and target directory. The [Benchmarking guide](BENCHMARKING.md) owns benchmark commands, methodology, baselines, output locations, and report promotion. The [Scripts guide](../scripts/README.md) -owns the Python script inventory and entry points for comparisons, plotting, -and release metadata. The pinned published `research-repo-tools` dependency +owns the Python script inventory and entry points for comparisons and plotting. +The pinned published `research-repo-tools` dependency owns changelog generation, normalization, minor-series archiving, note lookup, and tag preparation through its CLI. Consumer policy stays in `cliff.toml`, `changelog-rumdl.toml`, and `[tool.research-repo-tools]` in `pyproject.toml`; @@ -119,6 +119,20 @@ with local stubs; the [contributor review workflow](../CONTRIBUTING.md#coderabbi owns prerequisites and invocation policy. The [justfile](../justfile) owns executable development workflows. +The shared package also owns managed tool installation, verification, and update +implementation. `.python-version`, `rust-toolchain.toml`, and `pyproject.toml` +own consumer declarations; `scripts/tests/test_toolchain_integration.py` checks +actual recipe sequencing and native managed execution. The +`.github/actions/setup-tools/action.yml` composite synchronizes the locked PyPI +package and exports verified paths for CI. Release callers disable its caches. + +Release metadata, version checks, Markdown line checks, and Semgrep fixture +validation also belong to the shared CLI. Consumer policy stays in +`pyproject.toml`; `scripts/tests/test_maintenance_integration.py` verifies the +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. + `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/justfile b/justfile index 932c271..59d93fb 100644 --- a/justfile +++ b/justfile @@ -1,15 +1,12 @@ # shellcheck disable=SC2148 # Justfile for la-stack development workflow -# Install just: https://github.com/casey/just +# Install tools: uv run --locked --managed-python --only-group tooling research-repo-tools setup # Usage: just or just --list # Use bash with strict error handling for all recipes set shell := ["bash", "-euo", "pipefail", "-c"] -home_dir := env_var_or_default("HOME", env_var_or_default("USERPROFILE", "")) -cargo_home := env_var_or_default("CARGO_HOME", home_dir + "/.cargo") -path_separator := if os_family() == "windows" { ";" } else { ":" } -export PATH := cargo_home + "/bin" + path_separator + env_var("PATH") +_run := "uv run --locked --no-sync --no-python-downloads research-repo-tools toolchain run --" # Coverage (cargo-llvm-cov) # @@ -17,220 +14,29 @@ export PATH := cargo_home + "/bin" + path_separator + env_var("PATH") _coverage_base_args := '''--features exact \ --workspace --lib --tests \ --verbose''' -cargo_edit_version := "0.13.13" -cargo_llvm_cov_version := "0.9.1" -cargo_machete_version := "0.9.2" -cargo_nextest_version := "0.9.146" -cargo_update_version := "22.1.1" -clippy_sarif_version := "0.8.0" -dprint_version := "0.58.0" -git_cliff_version := "2.14.2" -just_version := "1.58.0" -rumdl_version := "0.2.78" -sarif_fmt_version := "0.8.0" -taplo_version := "0.10.0" -typos_version := "1.50.3" -uv_version := "0.12.21" -zizmor_version := "1.30.1" - -# Internal helpers: ensure external tooling is installed -_ensure-actionlint: _ensure-uv - #!/usr/bin/env bash - set -euo pipefail - uv run --locked actionlint -version >/dev/null - -_ensure-cargo-edit: - #!/usr/bin/env bash - set -euo pipefail - installed_version="" - if cargo upgrade --version >/dev/null 2>&1; then - installed_version="$(cargo upgrade --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" - fi - if [[ "$installed_version" != "{{ cargo_edit_version }}" ]]; then - echo "❌ 'cargo-edit' {{ cargo_edit_version }} not found. Install with:" - echo " cargo install --locked cargo-edit --version {{ cargo_edit_version }}" - exit 1 - fi - -_ensure-cargo-install-update: - #!/usr/bin/env bash - set -euo pipefail - command -v cargo-install-update >/dev/null || { - echo "❌ 'cargo-install-update' not found. Run 'just setup-tools' or install it with:" - echo " cargo install --locked cargo-update" - exit 1 - } - -_ensure-cargo-llvm-cov: - #!/usr/bin/env bash - set -euo pipefail - installed_version="" - if command -v cargo-llvm-cov >/dev/null; then - installed_version="$(cargo llvm-cov --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" - fi - if [[ "$installed_version" != "{{ cargo_llvm_cov_version }}" ]]; then - echo "❌ 'cargo-llvm-cov' {{ cargo_llvm_cov_version }} not found. Install with:" - echo " cargo install --locked cargo-llvm-cov --version {{ cargo_llvm_cov_version }}" - exit 1 - fi - -_ensure-cargo-machete: - #!/usr/bin/env bash - set -euo pipefail - installed_version="" - if cargo machete --version >/dev/null 2>&1; then - installed_version="$(cargo machete --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" - fi - if [[ "$installed_version" != "{{ cargo_machete_version }}" ]]; then - echo "❌ 'cargo-machete' {{ cargo_machete_version }} not found. Install with:" - echo " cargo install --locked cargo-machete --version {{ cargo_machete_version }}" - exit 1 - fi - -_ensure-cargo-nextest: - #!/usr/bin/env bash - set -euo pipefail - installed_version="" - if cargo nextest --version >/dev/null 2>&1; then - installed_version="$(cargo nextest --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" - fi - if [[ "$installed_version" != "{{ cargo_nextest_version }}" ]]; then - echo "❌ 'cargo-nextest' {{ cargo_nextest_version }} not found. Install with:" - echo " cargo install --locked cargo-nextest --version {{ cargo_nextest_version }}" - exit 1 - fi - -_ensure-dprint: - #!/usr/bin/env bash - set -euo pipefail - installed_version="" - if command -v dprint >/dev/null; then - installed_version="$(dprint --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" - fi - if [[ "$installed_version" != "{{ dprint_version }}" ]]; then - echo "❌ 'dprint' {{ dprint_version }} not found. Install with:" - echo " cargo install --locked dprint --version {{ dprint_version }}" - exit 1 - fi - +# System prerequisites remain consumer-owned. _ensure-gh: - #!/usr/bin/env bash - set -euo pipefail - command -v gh >/dev/null || { echo "❌ 'gh' not found. Install GitHub CLI and re-run this command."; exit 1; } - -_ensure-git-cliff: - #!/usr/bin/env bash - set -euo pipefail - installed_version="" - if command -v git-cliff >/dev/null; then - installed_version="$(git-cliff --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" - fi - if [[ "$installed_version" != "{{ git_cliff_version }}" ]]; then - echo "❌ 'git-cliff' {{ git_cliff_version }} not found. Install with:" - echo " cargo install --locked git-cliff --version {{ git_cliff_version }}" - exit 1 - fi + @command -v gh >/dev/null || { echo "GitHub CLI is required on PATH." >&2; exit 1; } _ensure-jq: - #!/usr/bin/env bash - set -euo pipefail - command -v jq >/dev/null || { echo "❌ 'jq' not found. Install jq and re-run this command."; exit 1; } + @command -v jq >/dev/null || { echo "jq is required on PATH." >&2; exit 1; } -_ensure-rumdl: - #!/usr/bin/env bash - set -euo pipefail - installed_version="" - if command -v rumdl >/dev/null; then - installed_version="$(rumdl --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" - fi - if [[ "$installed_version" != "{{ rumdl_version }}" ]]; then - echo "❌ 'rumdl' {{ rumdl_version }} not found. Install with:" - echo " cargo install --locked rumdl --version {{ rumdl_version }}" - exit 1 - fi +# 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 - #!/usr/bin/env bash - set -euo pipefail uv run --locked shellcheck --version >/dev/null _ensure-shfmt: _ensure-uv - #!/usr/bin/env bash - set -euo pipefail uv run --locked shfmt --version >/dev/null -_ensure-taplo: - #!/usr/bin/env bash - set -euo pipefail - installed_version="" - if command -v taplo >/dev/null; then - installed_version="$(taplo --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" - fi - if [[ "$installed_version" != "{{ taplo_version }}" ]]; then - echo "❌ 'taplo' {{ taplo_version }} not found. Install with:" - echo " cargo install --locked taplo-cli --version {{ taplo_version }}" - exit 1 - fi - -# Internal helper: ensure typos-cli is installed -_ensure-typos: - #!/usr/bin/env bash - set -euo pipefail - installed_version="" - if command -v typos >/dev/null; then - installed_version="$(typos --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" - fi - if [[ "$installed_version" != "{{ typos_version }}" ]]; then - echo "❌ 'typos' {{ typos_version }} not found. Install with:" - echo " cargo install --locked typos-cli --version {{ typos_version }}" - exit 1 - fi - -_ensure-uv: _ensure-uv-available - #!/usr/bin/env bash - set -euo pipefail - resolved="$(command -v uv 2>/dev/null || true)" - actual="$(uv --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" - if [[ "$actual" != "{{ uv_version }}" ]]; then - echo "❌ 'uv' resolves to '${resolved:-missing}' at version '${actual:-missing}', expected '{{ uv_version }}'." >&2 - echo " Install uv {{ uv_version }} and re-run: just setup-tools" >&2 - exit 1 - fi - -_ensure-uv-available: - #!/usr/bin/env bash - set -euo pipefail - command -v uv >/dev/null || { - echo "❌ 'uv' not found. Install it from https://github.com/astral-sh/uv" >&2 - exit 1 - } - uv --version >/dev/null - -_ensure-stable-uv-version: _ensure-uv-available - #!/usr/bin/env bash - set -euo pipefail - uv_executable="$(command -v uv)" - version_output="$("$uv_executable" --version)" - "$uv_executable" run --locked update-cargo-tool-pins "--check-uv-version=$version_output" - _ensure-yamllint: _ensure-uv - #!/usr/bin/env bash - set -euo pipefail uv run --locked yamllint --version >/dev/null -_ensure-zizmor: - #!/usr/bin/env bash - set -euo pipefail - installed_version="" - if command -v zizmor >/dev/null; then - installed_version="$(zizmor --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" - fi - if [[ "$installed_version" != "{{ zizmor_version }}" ]]; then - echo "❌ 'zizmor' {{ zizmor_version }} not found. Install with:" - echo " cargo install --locked zizmor --version {{ zizmor_version }}" - exit 1 - fi - # GitHub Actions workflow validation action-lint: _ensure-actionlint #!/usr/bin/env bash @@ -249,7 +55,7 @@ action-lint: _ensure-actionlint # Benchmarks bench: - cargo bench --locked --workspace --features bench + {{ _run }} cargo bench --locked --workspace --features bench # Compare latest measurements against a saved baseline. # Defaults to the `last` full-release baseline. @@ -257,41 +63,41 @@ bench-compare baseline="last" suite="all" scope="release-signal": python-sync #!/usr/bin/env bash set -euo pipefail baseline={{ quote(baseline) }} - uv run --locked bench-compare "$baseline" --suite {{ quote(suite) }} --scope {{ quote(scope) }} + {{ _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. bench-compile: - CARGO_BUILD_WARNINGS=deny cargo bench --locked --workspace --no-run --features bench - CARGO_BUILD_WARNINGS=deny cargo bench --locked --no-run --features bench,exact --bench exact + 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 # Run the exact-arithmetic benchmark suite. bench-exact: - cargo bench --locked --features bench,exact --bench exact + {{ _run }} cargo bench --locked --features bench,exact --bench exact # Run the outward-rounded interval determinant benchmark suite. bench-interval: - cargo bench --locked --features bench --bench interval + {{ _run }} cargo bench --locked --features bench --bench interval # Run the certified dot-product and affine-difference benchmark suite. bench-linear-form: - cargo bench --locked --features bench --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 # Run latest measurements and render the latest-vs-last performance report. bench-latest-vs-last baseline="last": bench-latest python-sync - uv run --locked bench-compare {{ quote(baseline) }} + {{ _run }} uv run --locked bench-compare {{ quote(baseline) }} # Discover all release benchmarks and report their expected measurement budget. bench-release-inventory: _ensure-uv - uv run --locked scripts/release_baseline.py inventory + {{ _run }} uv run --locked scripts/release_baseline.py inventory # Check every discovered benchmark before the release workflow packages it. bench-release-check tag: _ensure-uv - uv run --locked scripts/release_baseline.py validate --baseline {{ quote(tag) }} + {{ _run }} uv run --locked scripts/release_baseline.py validate --baseline {{ quote(tag) }} # Save a Criterion baseline. Defaults to all release-signal benchmark suites. bench-save-baseline tag suite="all": @@ -300,14 +106,14 @@ bench-save-baseline tag suite="all": suite={{ quote(suite) }} case "$suite" in all) - cargo bench --locked -p la-stack-comparison --features bench --bench vs_linalg -- --noplot --save-baseline {{ quote(tag) }} - cargo bench --locked --features bench,exact --bench exact -- --noplot --save-baseline {{ quote(tag) }} + {{ _run }} cargo bench --locked -p la-stack-comparison --features bench --bench vs_linalg -- --noplot --save-baseline {{ quote(tag) }} + {{ _run }} cargo bench --locked --features bench,exact --bench exact -- --noplot --save-baseline {{ quote(tag) }} ;; exact) - cargo bench --locked --features bench,exact --bench exact -- --noplot --save-baseline {{ quote(tag) }} + {{ _run }} cargo bench --locked --features bench,exact --bench exact -- --noplot --save-baseline {{ quote(tag) }} ;; vs_linalg) - cargo bench --locked -p la-stack-comparison --features bench --bench vs_linalg -- --noplot --save-baseline {{ quote(tag) }} + {{ _run }} cargo bench --locked -p la-stack-comparison --features bench --bench vs_linalg -- --noplot --save-baseline {{ quote(tag) }} ;; *) echo "unknown benchmark suite: $suite" >&2 @@ -325,19 +131,19 @@ bench-vs-linalg filter="": set -euo pipefail filter={{ quote(filter) }} if [ -n "$filter" ]; then - cargo bench --locked -p la-stack-comparison --features bench --bench vs_linalg -- "$filter" + {{ _run }} cargo bench --locked -p la-stack-comparison --features bench --bench vs_linalg -- "$filter" else - cargo bench --locked -p la-stack-comparison --features bench --bench vs_linalg + {{ _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. bench-vs-linalg-la-stack: - cargo bench --locked -p la-stack-comparison --features bench --bench vs_linalg -- la_stack --noplot + {{ _run }} cargo bench --locked -p la-stack-comparison --features bench --bench vs_linalg -- la_stack --noplot # Run only la-stack vs_linalg measurements and render a non-exact performance report. bench-vs-linalg-latest-vs baseline="last": bench-vs-linalg-la-stack python-sync - uv run --locked bench-compare {{ quote(baseline) }} --suite vs_linalg --scope release-signal + {{ _run }} uv run --locked bench-compare {{ quote(baseline) }} --suite vs_linalg --scope release-signal # Quick iteration (reduced runtime, no Criterion HTML). bench-vs-linalg-quick filter="": @@ -345,48 +151,48 @@ bench-vs-linalg-quick filter="": set -euo pipefail filter={{ quote(filter) }} if [ -n "$filter" ]; then - cargo bench --locked -p la-stack-comparison --features bench --bench vs_linalg -- "$filter" --quick --noplot + {{ _run }} cargo bench --locked -p la-stack-comparison --features bench --bench vs_linalg -- "$filter" --quick --noplot else - cargo bench --locked -p la-stack-comparison --features bench --bench vs_linalg -- --quick --noplot + {{ _run }} cargo bench --locked -p la-stack-comparison --features bench --bench vs_linalg -- --quick --noplot fi # Build commands build: - cargo build + {{ _run }} cargo build build-release: - cargo build --release + {{ _run }} cargo build --release # Verify Cargo.toml and the committed Cargo.lock are synchronized. cargo-lock-check: - cargo metadata --locked --format-version 1 --no-deps > /dev/null + {{ _run }} cargo metadata --locked --format-version 1 --no-deps > /dev/null # Generate, normalize, and rotate completed minor series with the pinned shared CLI. -changelog: _ensure-git-cliff _ensure-rumdl python-sync - uv run --locked --group dev research-repo-tools changelog generate +changelog: tools-check python-sync + {{ _run }} research-repo-tools changelog generate # Rotate existing history without regenerating release notes. changelog-archive: python-sync uv run --locked --group dev research-repo-tools changelog archive # Check release headings and all archives without writing files. -changelog-check: _ensure-rumdl python-sync +changelog-check: tools-check python-sync #!/usr/bin/env bash set -euo pipefail shopt -s nullglob uv run --locked --group dev research-repo-tools changelog check - rumdl check --no-cache --config pyproject.toml CHANGELOG.md docs/archives/changelog/*.md + {{ _run }} rumdl check --no-cache --config pyproject.toml CHANGELOG.md docs/archives/changelog/*.md # Validate generation and print the root changelog without publishing candidates. [positional-arguments] -changelog-preview *args: _ensure-git-cliff _ensure-rumdl python-sync +changelog-preview *args: tools-check python-sync #!/usr/bin/env bash set -euo pipefail - uv run --locked --group dev research-repo-tools changelog generate --dry-run "$@" + {{ _run }} research-repo-tools changelog generate --dry-run "$@" # Generate a prospective release using the explicit ISO date, without updating metadata. -changelog-release tag date: _ensure-git-cliff _ensure-rumdl python-sync - uv run --locked --group dev research-repo-tools changelog generate --tag {{ quote(tag) }} --date {{ quote(date) }} +changelog-release tag date: tools-check python-sync + {{ _run }} research-repo-tools changelog generate --tag {{ quote(tag) }} --date {{ quote(date) }} alias changelog-unreleased := changelog-release @@ -396,7 +202,7 @@ check: lint # Fast compile check (no binary produced) check-fast: - cargo check + {{ _run }} cargo check # CI simulation: flat GitHub-equivalent union of leaf validators. # Keep this dependency list explicit so each validation surface runs once without @@ -410,7 +216,7 @@ citation-check: _ensure-uv # Clean build artifacts clean: - cargo clean + {{ _run }} cargo clean rm -rf target/llvm-cov rm -rf coverage @@ -418,34 +224,34 @@ clean: clippy: clippy-all-targets clippy-all-targets: - cargo clippy --workspace --all-targets - cargo clippy --workspace --all-targets --all-features + {{ _run }} cargo clippy --workspace --all-targets + {{ _run }} cargo clippy --workspace --all-targets --all-features # Core library Clippy checks used by the orthogonal CI graph. clippy-core: - cargo clippy --workspace --lib - cargo clippy --workspace --lib --all-features + {{ _run }} cargo clippy --workspace --lib + {{ _run }} cargo clippy --workspace --lib --all-features # Clippy for the "exact" feature (catches feature-gated lint issues) clippy-exact: - cargo clippy --features exact --all-targets + {{ _run }} cargo clippy --features exact --all-targets # Coverage analysis for local development (HTML output) -coverage: _ensure-cargo-llvm-cov _ensure-cargo-nextest +coverage: tools-check #!/usr/bin/env bash set -euo pipefail mkdir -p target/llvm-cov - cargo llvm-cov nextest {{ _coverage_base_args }} --open --output-dir target/llvm-cov -P coverage + {{ _run }} cargo llvm-cov nextest {{ _coverage_base_args }} --open --output-dir target/llvm-cov -P coverage echo "Coverage report generated: target/llvm-cov/html/index.html" # Coverage analysis for CI (XML output for Codecov) -coverage-ci: _ensure-cargo-llvm-cov _ensure-cargo-nextest +coverage-ci: tools-check #!/usr/bin/env bash set -euo pipefail mkdir -p coverage - cargo llvm-cov nextest {{ _coverage_base_args }} --cobertura --output-path coverage/cobertura.xml -P coverage + {{ _run }} cargo llvm-cov nextest {{ _coverage_base_args }} --cobertura --output-path coverage/cobertura.xml -P coverage # Default recipe shows available commands default: @@ -453,17 +259,17 @@ default: # Documentation build checks for the default and exact-feature public APIs. doc-check: - RUSTDOCFLAGS='-D warnings' cargo doc --no-deps - RUSTDOCFLAGS='-D warnings' cargo doc --no-deps --features exact + RUSTDOCFLAGS='-D warnings' {{ _run }} cargo doc --no-deps + RUSTDOCFLAGS='-D warnings' {{ _run }} cargo doc --no-deps --features exact -docs-version-check: _ensure-uv - uv run --locked check-docs-version-sync +docs-version-check: python-sync + uv run --locked --group dev research-repo-tools release check # Examples examples: #!/usr/bin/env bash set -euo pipefail - cargo build --features exact --examples + {{ _run }} cargo build --features exact --examples exe_suffix="" if [[ "${OS:-}" == "Windows_NT" ]]; then @@ -486,10 +292,10 @@ fix: toml-fmt fmt python-fix shell-fmt markdown-fix yaml-fix # Rust formatting fmt: - cargo fmt --all + {{ _run }} cargo fmt --all fmt-check: - cargo fmt --all -- --check + {{ _run }} cargo fmt --all -- --check github-actions-check: action-lint zizmor @echo "✅ GitHub Actions checks complete!" @@ -542,7 +348,7 @@ help-workflows: @echo " just update-version # Update release metadata and infer the previous tag" @echo "" @echo "Setup:" - @echo " just setup # Setup project environment (depends on setup-tools)" + @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 "" @@ -579,7 +385,7 @@ lint-config: json-check toml-ci yaml-ci github-actions-check justfile-fmt-check lint-docs: markdown-ci docs-version-check changelog-check # Markdown -markdown-check: _ensure-rumdl _ensure-uv +markdown-check: tools-check _ensure-uv #!/usr/bin/env bash set -euo pipefail files=() @@ -592,8 +398,8 @@ markdown-check: _ensure-rumdl _ensure-uv fi done < <(git ls-files -co --exclude-standard -z -- '*.md') if [ "${#files[@]}" -gt 0 ]; then - printf '%s\0' "${files[@]}" | xargs -0 -n100 rumdl check - uv run --locked scripts/check_markdown_lines.py "${files[@]}" + printf '%s\0' "${files[@]}" | xargs -0 -n100 {{ _run }} rumdl check + uv run --locked --group dev research-repo-tools docs check-lines "${files[@]}" else echo "No markdown files found to check." fi @@ -601,7 +407,7 @@ markdown-check: _ensure-rumdl _ensure-uv markdown-ci: markdown-check spell-check @echo "✅ Markdown checks complete!" -markdown-fix: _ensure-rumdl +markdown-fix: tools-check #!/usr/bin/env bash set -euo pipefail files=() @@ -615,7 +421,7 @@ markdown-fix: _ensure-rumdl done < <(git ls-files -co --exclude-standard -z -- '*.md') if [ "${#files[@]}" -gt 0 ]; then echo "📝 rumdl check --fix (${#files[@]} files)" - printf '%s\0' "${files[@]}" | xargs -0 -n100 rumdl check --fix + printf '%s\0' "${files[@]}" | xargs -0 -n100 {{ _run }} rumdl check --fix else echo "No markdown files found to format." fi @@ -624,7 +430,7 @@ markdown-lint: markdown-check # Build release docs from retained scratch inputs or the latest docs/performance snapshot. performance-doc: python-sync - uv run --locked archive-performance --promote-artifacts + {{ _run }} uv run --locked archive-performance --promote-artifacts # Compare stored GitHub Actions release benchmark assets without local cargo runs. performance-github-assets current_tag="" baseline_tag="": _ensure-gh python-sync @@ -637,14 +443,14 @@ performance-github-assets current_tag="" baseline_tag="": _ensure-gh python-sync echo "current_tag and baseline_tag must be provided together" >&2 exit 2 fi - uv run --locked archive-performance "$current_tag" "$baseline_tag" --github-assets --generate-in-temp-worktree --worktree-ref "$current_tag" --output-only --output target/bench-reports/github-assets-performance.md --artifact-csv target/bench-reports/github-assets-performance.csv --artifact-provenance target/bench-reports/github-assets-performance.provenance.json + {{ _run }} uv run --locked archive-performance "$current_tag" "$baseline_tag" --github-assets --generate-in-temp-worktree --worktree-ref "$current_tag" --output-only --output target/bench-reports/github-assets-performance.md --artifact-csv target/bench-reports/github-assets-performance.csv --artifact-provenance target/bench-reports/github-assets-performance.provenance.json else - uv run --locked archive-performance --published-latest --github-assets --generate-in-temp-worktree --output-only --output target/bench-reports/github-assets-performance.md --artifact-csv target/bench-reports/github-assets-performance.csv --artifact-provenance target/bench-reports/github-assets-performance.provenance.json + {{ _run }} uv run --locked archive-performance --published-latest --github-assets --generate-in-temp-worktree --output-only --output target/bench-reports/github-assets-performance.md --artifact-csv target/bench-reports/github-assets-performance.csv --artifact-provenance target/bench-reports/github-assets-performance.provenance.json fi # Compare the current tree against the latest release; untracked files are excluded. performance-local: _ensure-gh python-sync - uv run --locked archive-performance --current-vs-latest --generate-in-temp-worktree --output-only --local-report --output target/bench-reports/performance.md + {{ _run }} uv run --locked archive-performance --current-vs-latest --generate-in-temp-worktree --output-only --local-report --output target/bench-reports/performance.md # Compare current non-exact kernels locally without rerunning current peer crates. performance-local-non-exact current_tag="" baseline_tag="": _ensure-gh python-sync @@ -657,9 +463,9 @@ performance-local-non-exact current_tag="" baseline_tag="": _ensure-gh python-sy echo "current_tag and baseline_tag must be provided together" >&2 exit 2 fi - uv run --locked archive-performance "$current_tag" "$baseline_tag" --suite vs_linalg --generate-in-temp-worktree --worktree-ref HEAD --output-only --local-report --output target/bench-reports/performance-non-exact.md --artifact-csv target/bench-reports/performance-non-exact.csv --artifact-provenance target/bench-reports/performance-non-exact.provenance.json + {{ _run }} uv run --locked archive-performance "$current_tag" "$baseline_tag" --suite vs_linalg --generate-in-temp-worktree --worktree-ref HEAD --output-only --local-report --output target/bench-reports/performance-non-exact.md --artifact-csv target/bench-reports/performance-non-exact.csv --artifact-provenance target/bench-reports/performance-non-exact.provenance.json else - uv run --locked archive-performance --current-vs-latest --suite vs_linalg --generate-in-temp-worktree --output-only --local-report --output target/bench-reports/performance-non-exact.md --artifact-csv target/bench-reports/performance-non-exact.csv --artifact-provenance target/bench-reports/performance-non-exact.provenance.json + {{ _run }} uv run --locked archive-performance --current-vs-latest --suite vs_linalg --generate-in-temp-worktree --output-only --local-report --output target/bench-reports/performance-non-exact.md --artifact-csv target/bench-reports/performance-non-exact.csv --artifact-provenance target/bench-reports/performance-non-exact.provenance.json fi # Publish README assets/table from retained measurements, including after target cleanup. @@ -670,7 +476,7 @@ performance-readme metric="lu_solve" stat="median" sample="new" log_y="true": py if [ {{ quote(log_y) }} = "true" ]; then args+=(--log-y) fi - uv run --locked criterion-dim-plot "${args[@]}" + {{ _run }} uv run --locked criterion-dim-plot "${args[@]}" # Measure locally, preserve complete summaries in docs/performance, and promote/archive docs. performance-release current_tag="" baseline_tag="": _ensure-gh python-sync @@ -683,9 +489,9 @@ performance-release current_tag="" baseline_tag="": _ensure-gh python-sync echo "current_tag and baseline_tag must be provided together" >&2 exit 2 fi - uv run --locked archive-performance "$current_tag" "$baseline_tag" --generate-in-temp-worktree --worktree-ref HEAD + {{ _run }} uv run --locked archive-performance "$current_tag" "$baseline_tag" --generate-in-temp-worktree --worktree-ref HEAD else - uv run --locked archive-performance --infer-release --generate-in-temp-worktree --worktree-ref HEAD + {{ _run }} uv run --locked archive-performance --infer-release --generate-in-temp-worktree --worktree-ref HEAD fi # Plot: generate a single time-vs-dimension SVG from Criterion results. @@ -699,7 +505,7 @@ plot-vs-linalg metric="lu_solve" stat="median" sample="new" log_y="false" allow_ if [ {{ quote(allow_partial) }} = "true" ]; then args+=(--allow-partial) fi - uv run --locked criterion-dim-plot "${args[@]}" + {{ _run }} uv run --locked criterion-dim-plot "${args[@]}" # Python tooling (uv) python-check: python-format-check python-lint python-fixture-lint python-typecheck @@ -738,6 +544,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 + +# 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 + +# 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!" @@ -746,184 +563,25 @@ 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 . # Fixture tests for repository-owned Semgrep rules. -semgrep-test: _ensure-uv - #!/usr/bin/env bash - set -euo pipefail - - check_semgrep_fixture() { - target="$1" - json="$(uv run --locked semgrep scan --metrics off --json --quiet --strict --config semgrep.yaml "$target")" - SEMGREP_JSON="$json" uv run --locked scripts/check_semgrep_fixtures.py "$target" - } - - while IFS= read -r -d '' fixture; do - check_semgrep_fixture "$fixture" - done < <(find tests/semgrep -type f ! -name '*.fixed' -print0) - -# Setup -setup: setup-tools - #!/usr/bin/env bash - set -euo pipefail - echo "Setting up la-stack development environment..." - echo "Note: Rust toolchain and components managed by rust-toolchain.toml (if present)" - echo "" - - echo "Building project..." - cargo build - echo "✅ Setup complete! Run 'just help-workflows' to see available commands." - -# Development tooling installation and verification -setup-tools: - #!/usr/bin/env bash - set -euo pipefail - - have() { command -v "$1" >/dev/null 2>&1; } - - installed_tool_version() { - case "$1" in - cargo-llvm-cov) - cargo llvm-cov --version 2>/dev/null - ;; - cargo-machete) - cargo machete --version 2>/dev/null - ;; - cargo-nextest) - cargo nextest --version 2>/dev/null - ;; - cargo-upgrade) - cargo upgrade --version 2>/dev/null - ;; - *) - "$1" --version 2>/dev/null - ;; - esac | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true - } - - verify_tool_version() { - local cmd="$1" - local expected="$2" - local actual="" - local resolved="" - - actual="$(installed_tool_version "$cmd")" - resolved="$(command -v "$cmd" 2>/dev/null || true)" - if [[ "$actual" != "$expected" ]]; then - echo "❌ '$cmd' resolves to '${resolved:-missing}' at version '${actual:-missing}', expected '$expected'." >&2 - return 1 - fi - echo " ✓ $cmd $actual" - } - - echo "🔧 Ensuring tooling required by just recipes is installed..." - echo "" - uv_version="{{ uv_version }}" - if ! have uv; then - echo "❌ 'uv' not found. Install uv $uv_version and re-run: just setup-tools" >&2 - exit 1 - fi - verify_tool_version uv "$uv_version" - if ! have jq; then - echo "❌ 'jq' not found. Install jq and re-run: just setup-tools" >&2 - exit 1 - fi - if ! have gh; then - echo "❌ 'gh' not found. Install GitHub CLI and re-run: just setup-tools" >&2 - exit 1 - fi - - echo "Ensuring Rust components..." - if ! have rustup; then - echo "❌ 'rustup' not found. Install Rust via https://rustup.rs and re-run: just setup-tools" - exit 1 - fi - rustup component add clippy rustfmt rust-src llvm-tools-preview - echo "" - - echo "Ensuring cargo tools..." - just_version="{{ just_version }}" - if ! have just || [[ "$(just --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$just_version" ]]; then - cargo install --locked just --version "$just_version" - fi - - cargo_update_version="{{ cargo_update_version }}" - if ! have cargo-install-update || [[ "$(cargo-install-update --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$cargo_update_version" ]]; then - cargo install --locked cargo-update --version "$cargo_update_version" - fi - - cargo_edit_version="{{ cargo_edit_version }}" - if ! cargo upgrade --version >/dev/null 2>&1 || [[ "$(cargo upgrade --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$cargo_edit_version" ]]; then - cargo install --locked cargo-edit --version "$cargo_edit_version" - fi +semgrep-test: python-sync + uv run --locked --group dev research-repo-tools semgrep check-fixtures - cargo_llvm_cov_version="{{ cargo_llvm_cov_version }}" - if ! have cargo-llvm-cov || [[ "$(cargo llvm-cov --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$cargo_llvm_cov_version" ]]; then - cargo install --locked cargo-llvm-cov --version "$cargo_llvm_cov_version" - fi - cargo_machete_version="{{ cargo_machete_version }}" - if ! cargo machete --version >/dev/null 2>&1 || [[ "$(cargo machete --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$cargo_machete_version" ]]; then - cargo install --locked cargo-machete --version "$cargo_machete_version" - fi +# Install declared managed tools and explicitly synchronize dev. +setup: _ensure-gh _ensure-jq + uv run --locked --managed-python --only-group tooling research-repo-tools setup + {{ _run }} cargo build - cargo_nextest_version="{{ cargo_nextest_version }}" - if ! cargo nextest --version >/dev/null 2>&1 || [[ "$(cargo nextest --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$cargo_nextest_version" ]]; then - cargo install --locked cargo-nextest --version "$cargo_nextest_version" - fi - dprint_version="{{ dprint_version }}" - if ! have dprint || [[ "$(dprint --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$dprint_version" ]]; then - cargo install --locked dprint --version "$dprint_version" - fi - git_cliff_version="{{ git_cliff_version }}" - if ! have git-cliff || [[ "$(git-cliff --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$git_cliff_version" ]]; then - cargo install --locked git-cliff --version "$git_cliff_version" - fi - rumdl_version="{{ rumdl_version }}" - if ! have rumdl || [[ "$(rumdl --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$rumdl_version" ]]; then - cargo install --locked rumdl --version "$rumdl_version" - fi - taplo_version="{{ taplo_version }}" - if ! have taplo || [[ "$(taplo --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$taplo_version" ]]; then - cargo install --locked taplo-cli --version "$taplo_version" - fi - typos_version="{{ typos_version }}" - if ! have typos || [[ "$(typos --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$typos_version" ]]; then - cargo install --locked typos-cli --version "$typos_version" - fi - zizmor_version="{{ zizmor_version }}" - if ! have zizmor || [[ "$(zizmor --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" != "$zizmor_version" ]]; then - cargo install --locked zizmor --version "$zizmor_version" - fi - echo "" +# Tool-only setup; compilation belongs to setup. +setup-tools: _ensure-gh _ensure-jq + uv run --locked --managed-python --only-group tooling research-repo-tools setup - echo "Ensuring uv-managed Python tools..." - uv sync --locked --group dev - echo "" - - echo "" - echo "Verifying required commands and versions..." - have jq || { echo "❌ 'jq' is still missing."; exit 1; } - echo " ✓ jq" - verify_tool_version just "$just_version" - verify_tool_version cargo-install-update "$cargo_update_version" - verify_tool_version cargo-upgrade "$cargo_edit_version" - verify_tool_version cargo-llvm-cov "$cargo_llvm_cov_version" - verify_tool_version cargo-machete "$cargo_machete_version" - verify_tool_version cargo-nextest "$cargo_nextest_version" - verify_tool_version dprint "$dprint_version" - verify_tool_version git-cliff "$git_cliff_version" - verify_tool_version rumdl "$rumdl_version" - verify_tool_version taplo "$taplo_version" - verify_tool_version typos "$typos_version" - verify_tool_version uv "$uv_version" - verify_tool_version zizmor "$zizmor_version" - uv run --locked actionlint -version >/dev/null - echo " ✓ actionlint (uv)" - for cmd in pytest ruff semgrep shellcheck shfmt ty yamllint; do - uv run --locked "$cmd" --version >/dev/null - echo " ✓ $cmd (uv)" - done +# Check installed tools without synchronization, downloads, or installation. +tools-check: + uv run --locked --no-sync --no-python-downloads research-repo-tools toolchain check - echo "" - echo "✅ Tooling setup complete." +# 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 shell-check: _ensure-shellcheck _ensure-shfmt @@ -963,7 +621,7 @@ shell-fmt: _ensure-shfmt shell-lint: shell-check # Spell check (typos) -spell-check: _ensure-typos +spell-check: tools-check #!/usr/bin/env bash set -euo pipefail files=() @@ -976,7 +634,7 @@ spell-check: _ensure-typos done < <(git ls-files -co --exclude-standard -z) if [ "${#files[@]}" -gt 0 ]; then # Exclude typos.toml itself: it intentionally contains allowlisted fragments. - printf '%s\0' "${files[@]}" | xargs -0 -n100 typos --config typos.toml --force-exclude --exclude typos.toml -- + printf '%s\0' "${files[@]}" | xargs -0 -n100 {{ _run }} typos --config typos.toml --force-exclude --exclude typos.toml -- else echo "No files found to spell-check." fi @@ -996,38 +654,38 @@ test-all: test-rust test-python @echo "✅ All tests passed" # Smoke-test deterministic inputs and configuration shared with benchmark suites. -test-bench-inputs: _ensure-cargo-nextest - cargo nextest run --workspace --profile ci --features bench,exact --test vs_linalg_inputs --test exact_bench_config --verbose +test-bench-inputs: tools-check + {{ _run }} cargo nextest run --workspace --profile ci --features bench,exact --test vs_linalg_inputs --test exact_bench_config --verbose test-doc: - cargo test --doc --verbose + {{ _run }} cargo test --doc --verbose test-doc-exact: - cargo test --features exact --doc --verbose + {{ _run }} cargo test --features exact --doc --verbose # Tests for the "exact" feature (exact determinants, conversions, and Bareiss solves) -test-exact: _ensure-cargo-nextest test-doc-exact - cargo nextest run --profile ci --features exact --verbose +test-exact: tools-check test-doc-exact + {{ _run }} cargo nextest run --profile ci --features exact --verbose -test-integration: _ensure-cargo-nextest - cargo nextest run --profile ci --tests --verbose +test-integration: tools-check + {{ _run }} cargo nextest run --profile ci --tests --verbose # Compile all integration-test targets without running them. -test-integration-compile: _ensure-cargo-nextest - cargo nextest run --all-features --tests --no-run +test-integration-compile: tools-check + {{ _run }} cargo nextest run --all-features --tests --no-run -test-lib: _ensure-cargo-nextest - cargo nextest run --profile ci --lib --verbose +test-lib: tools-check + {{ _run }} cargo nextest run --profile ci --lib --verbose -test-python: _ensure-git-cliff python-sync - uv run --locked pytest -q +test-python: tools-check python-sync + {{ _run }} uv run --locked pytest -q test-rust: test-rust-ci test-doc test-doc-exact @echo "✅ Rust tests passed" # CI Rust bucket: all runnable unit/integration targets in one nextest pass. -test-rust-ci: _ensure-cargo-nextest - cargo nextest run --workspace --release --profile ci --all-features --lib --tests --verbose +test-rust-ci: tools-check + {{ _run }} cargo nextest run --workspace --release --profile ci --all-features --lib --tests --verbose test-unit: test-lib @@ -1039,7 +697,7 @@ toml-ci: toml-check toml-fix: toml-fmt -toml-fmt: _ensure-taplo +toml-fmt: tools-check #!/usr/bin/env bash set -euo pipefail files=() @@ -1049,12 +707,12 @@ toml-fmt: _ensure-taplo fi done < <(git ls-files -co --exclude-standard -z -- '*.toml') if [ "${#files[@]}" -gt 0 ]; then - taplo fmt "${files[@]}" + {{ _run }} taplo fmt "${files[@]}" else echo "No TOML files found to format." fi -toml-fmt-check: _ensure-taplo +toml-fmt-check: tools-check #!/usr/bin/env bash set -euo pipefail files=() @@ -1064,12 +722,12 @@ toml-fmt-check: _ensure-taplo fi done < <(git ls-files -co --exclude-standard -z -- '*.toml') if [ "${#files[@]}" -gt 0 ]; then - taplo fmt --check "${files[@]}" + {{ _run }} taplo fmt --check "${files[@]}" else echo "No TOML files found to check." fi -toml-lint: _ensure-taplo +toml-lint: tools-check #!/usr/bin/env bash set -euo pipefail files=() @@ -1079,7 +737,7 @@ toml-lint: _ensure-taplo fi done < <(git ls-files -co --exclude-standard -z -- '*.toml') if [ "${#files[@]}" -gt 0 ]; then - taplo lint "${files[@]}" + {{ _run }} taplo lint "${files[@]}" else echo "No TOML files found to lint." fi @@ -1101,60 +759,45 @@ toml-parse-check: python-sync fi # Check for unused direct Cargo dependencies. -unused-deps: _ensure-cargo-machete - cargo machete +unused-deps: tools-check + {{ _run }} cargo machete -# Update dependency requirements, locks, managed Cargo tools, and the active uv pin. -update: _ensure-cargo-install-update _ensure-stable-uv-version update-dependencies update-cargo-tools +# Upgrade tools before updating dependency requirements and lock resolutions. +update: update-tools update-dependencies @echo "✅ Repository dependencies and tools updated." -# Update locally installed Cargo CLI tools and reconcile their pins plus the active uv version. -[doc('Update managed Cargo CLI tools and reconcile all root justfile tool pins.')] -update-cargo-tools: _ensure-stable-uv-version _ensure-cargo-install-update - #!/usr/bin/env bash - set -euo pipefail +# 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 + +# Upgrade only declared managed Cargo tools and publish verified TOML pins. +update-cargo-tools: + uv run --locked --only-group tooling --inexact research-repo-tools toolchain upgrade + +# Dependency-only updates leave uv and managed Cargo tool pins unchanged. +update-dependencies: update-cargo-dependencies update-python-dependencies - packages=( - cargo-edit - cargo-llvm-cov - cargo-machete - cargo-nextest - cargo-update - dprint - git-cliff - just - rumdl - taplo-cli - typos-cli - zizmor - ) - cargo install-update --locked "${packages[@]}" - uv run --locked update-cargo-tool-pins - -# Advance Cargo and exact Python development requirements plus their lockfiles. -[doc('Update Cargo and Python development requirements plus all Cargo/uv locked dependencies.')] -update-dependencies: _ensure-cargo-edit _ensure-stable-uv-version update-cargo-dependencies update-python-dependencies - -# Advance Cargo dependency declarations and lockfile entries. -[doc('Update Cargo.toml dependency requirements and Cargo.lock.')] -update-cargo-dependencies: _ensure-cargo-edit - # num-bigint and num-rational share public types and must advance together. - cargo upgrade --incompatible allow --exclude num-bigint --exclude num-rational - cargo update - -# Resolve latest Python development tools, retain exact pins, and sync the environment. -[doc('Update exact dependency-groups.dev pins and uv.lock through uv.')] -update-python-dependencies: _ensure-uv-available - uv run --locked update-python-dev-pins +# 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 uv lock --upgrade - uv sync --locked --group dev + {{ _run }} uv sync --locked --managed-python --group dev + +alias update-python-deps := update-python-dependencies # Update deterministic release metadata, inferring the previous stable published GitHub release. [doc('Update package, citation, lockfile, and non-artifact documentation release versions.')] -update-version tag: _ensure-gh _ensure-uv - uv run --locked update-release-version {{ quote(tag) }} - cargo metadata --locked --format-version 1 --no-deps > /dev/null - uv run --locked check-docs-version-sync +[positional-arguments] +update-version tag *args: _ensure-gh python-sync + {{ _run }} research-repo-tools release update "$@" validate-json: _ensure-jq #!/usr/bin/env bash @@ -1177,7 +820,7 @@ yaml-check: yaml-fmt-check yaml-lint yaml-ci: yaml-check citation-check @echo "✅ YAML/CFF checks complete!" -yaml-fix: _ensure-dprint +yaml-fix: tools-check #!/usr/bin/env bash set -euo pipefail files=() @@ -1187,12 +830,12 @@ yaml-fix: _ensure-dprint fi done < <(git ls-files -co --exclude-standard -z -- '*.yml' '*.yaml' 'CITATION.cff') if [ "${#files[@]}" -gt 0 ]; then - printf '%s\0' "${files[@]}" | xargs -0 dprint fmt --incremental=false + printf '%s\0' "${files[@]}" | xargs -0 {{ _run }} dprint fmt --incremental=false else echo "No YAML files found to format." fi -yaml-fmt-check: _ensure-dprint +yaml-fmt-check: tools-check #!/usr/bin/env bash set -euo pipefail files=() @@ -1202,7 +845,7 @@ yaml-fmt-check: _ensure-dprint fi done < <(git ls-files -co --exclude-standard -z -- '*.yml' '*.yaml' 'CITATION.cff') if [ "${#files[@]}" -gt 0 ]; then - printf '%s\0' "${files[@]}" | xargs -0 dprint check --incremental=false + printf '%s\0' "${files[@]}" | xargs -0 {{ _run }} dprint check --incremental=false else echo "No YAML files found to check." fi @@ -1224,5 +867,5 @@ yaml-lint: _ensure-yamllint fi # GitHub Actions security analysis -zizmor: _ensure-zizmor - @bash scripts/run_zizmor.sh +zizmor: tools-check + @{{ _run }} bash scripts/run_zizmor.sh diff --git a/pyproject.toml b/pyproject.toml index ed58908..c864e6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,15 +37,11 @@ dependencies = [ ] archive-performance = "archive_performance:main" bench-compare = "bench_compare:main" criterion-dim-plot = "criterion_dim_plot:main" -check-docs-version-sync = "check_docs_version_sync:main" -update-release-version = "update_release_version:main" -update-cargo-tool-pins = "update_cargo_tool_pins:main" -update-python-dev-pins = "update_python_dev_pins:main" # Configure setuptools to find modules in scripts/ directory. [tool.setuptools] package-dir = { "" = "scripts" } -py-modules = [ "archive_performance", "bench_compare", "benchmark_contract", "benchmark_summaries", "check_docs_version_sync", "check_semgrep_fixtures", "criterion_dim_plot", "criterion_measurements", "performance_artifacts", "subprocess_utils", "update_cargo_tool_pins", "update_python_dev_pins", "update_release_version" ] +py-modules = [ "archive_performance", "bench_compare", "benchmark_contract", "benchmark_summaries", "criterion_dim_plot", "criterion_measurements", "performance_artifacts", "subprocess_utils" ] [tool.ruff] line-length = 160 @@ -93,13 +89,10 @@ known-first-party = [ "bench_compare", "benchmark_contract", "benchmark_summaries", - "check_semgrep_fixtures", "criterion_dim_plot", "criterion_measurements", "performance_artifacts", "subprocess_utils", - "update_cargo_tool_pins", - "update_python_dev_pins", ] force-single-line = false split-on-trailing-comma = true @@ -145,6 +138,10 @@ line-length = 160 [tool.uv] package = true +required-version = "==0.12.21" +# Semgrep 1.178.0 restricts PyJWT to 2.13.x; 2.15.1 contains the OSV fixes. +# Keep crypto support while overriding that restriction until upstream updates it. +override-dependencies = [ "pyjwt[crypto]>=2.15.1,<3" ] [dependency-groups] tooling = [ "research-repo-tools==0.1.7" ] @@ -163,6 +160,24 @@ dev = [ [tool.research-repo-tools] schema = 1 +[tool.research-repo-tools.toolchain.binaries] +gitleaks = "8.30.1" +osv-scanner = "2.6.0" + +[tool.research-repo-tools.toolchain.cargo] +cargo-edit = "0.13.13" +cargo-llvm-cov = "0.9.1" +cargo-machete = "0.9.2" +cargo-nextest = "0.9.146" +clippy-sarif = "0.8.0" +dprint = "0.58.0" +git-cliff = "2.14.2" +rumdl = "0.2.78" +sarif-fmt = "0.8.0" +taplo-cli = "0.10.0" +typos-cli = "1.50.3" +zizmor = "1.30.1" + [tool.research-repo-tools.changelog] owner = "acgetchell" repository = "la-stack" @@ -173,3 +188,37 @@ cliff-config = "cliff.toml" [tool.research-repo-tools.release] # Release preparation and post-merge tagging can happen on different UTC days. date-policy = "declared" +tag-policy = "canonical-stable" +required-files = [ "Cargo.lock", "pyproject.toml", "uv.lock", "CITATION.cff", "CHANGELOG.md", "README.md", "REFERENCES.md" ] +# Retained scientific reports and measured links do not advance with metadata. +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 +exclude = '/docs/assets/bench/' + +[[tool.research-repo-tools.release.rules]] +path = "CITATION.cff" +pattern = '''^doi:[ \t]*['"]?(?P[^'"\s]+)['"]?[ \t]*(?:#.*)?\r?$''' +value = "10.5281/zenodo.18158926" + +[[tool.research-repo-tools.release.rules]] +path = "README.md" +pattern = 'https://doi\.org/(?P10\.5281/zenodo\.[0-9]+)' +count = 2 +value = "10.5281/zenodo.18158926" + +[[tool.research-repo-tools.release.rules]] +path = "REFERENCES.md" +pattern = 'https://doi\.org/(?P10\.5281/zenodo\.[0-9]+)' +value = "10.5281/zenodo.18158926" + +[tool.research-repo-tools.semgrep] +config = "semgrep.yaml" +fixtures = "tests/semgrep" +namespace = "la-stack." +cwd = "." +timeout = 120 diff --git a/rust-toolchain.toml b/rust-toolchain.toml index fd11025..ce793d2 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -2,13 +2,13 @@ # Pin to MSRV as specified in Cargo.toml channel = "1.98.1" -# Essential repository components. Keep checkout and CI setup lean; workflows -# install additional targets or components when they need them. +# Components used by the shared development and CI toolchain. components = [ "clippy", # Linting (you use strict pedantic mode) "rustfmt", # Code formatting (you use cargo fmt --all) "rust-src", # Source code for IDEs and local diagnostics + "llvm-tools-preview", # Coverage instrumentation ] -# Keep host installs small; CI passes matrix targets explicitly. +# Keep native host installs small. profile = "minimal" diff --git a/scripts/README.md b/scripts/README.md index 7c243cb..1c6ec51 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -26,15 +26,16 @@ uv sync --locked --group dev ### Updating dependencies and repository-owned tools -Run `just update` for the deliberate maintenance workflow. It updates Cargo and -exact Python development-tool declarations and their locks, upgrades only the -Cargo CLI packages owned by `setup-tools`, and then reconciles their installed -versions plus the active uv version with the root `justfile` atomically. All -required update tools are checked before the first dependency write, and the -maintenance workflow accepts a newer active uv so it can become the new pin. +Run `just update` for the deliberate shared maintenance workflow. Tool updates +run first: uv upgrades through its installation owner, declared managed Cargo +tools upgrade with verified TOML pins, and setup synchronizes the environment. +Just follows the shared package's `rust-just` dependency. Dependency updates then +advance Cargo requirements and exact direct Python `dev` pins, refresh both +locks, and explicitly synchronize `dev` with managed Rust available. A failed +step stops subsequent work without rolling back earlier package-manager steps. The coupled `num-bigint` and `num-rational` requirements are excluded from independent incompatible upgrades and must be advanced together. The Python -updater asks uv to resolve one cross-platform tool set before applying all +shared updater asks uv to resolve one cross-platform tool set before applying all changed exact pins together; it does not change runtime or build-system requirements. @@ -258,13 +259,23 @@ This repo has been tested with `gnuplot 6.0 patchlevel 3` (Homebrew `gnuplot 6.0 just update-version vX.Y.Z ``` -The updater infers the previous stable release from published GitHub releases, -updates package, lockfile, citation, non-artifact README, and active -benchmark workflow version references transactionally, and records the current -UTC date in `CITATION.cff`. It leaves README benchmark artifact links for -`performance-readme`, and it does not upgrade dependencies. If the target -changelog heading already exists, the updater advances its date atomically with -the citation date. +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 +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: + +```bash +just update-version vX.Y.Z --previous-release vA.B.C --date YYYY-MM-DD --dry-run +``` + +Consumer policy in `pyproject.toml` checks the concept DOI and the expected README +reference count. Measured benchmark links, retained performance reports, archived +documentation, and dependency versions stay unchanged. Benchmark command examples +use selected tag variables rather than mutable release literals. If the target +changelog heading already exists, its date advances with the citation date. +After metadata preparation, `just docs-version-check` requires generated notes +for the new version; generate them with the same declared date before validation. ### Generating the changelog @@ -293,7 +304,7 @@ common regressions belong to the shared package; this repository owns the configuration, recipes, and `scripts/tests/test_changelog_integration.py`. Internal shared modules are not a supported consumer API. -The consumer-owned `update-python` helper advances only exact requirements +The shared `deps update-python` command advances only exact requirements declared directly in `dev`. Included groups retain their own upgrade policy; the shared `tooling` pin and its lockfile change together through an intentional dependency upgrade. @@ -329,10 +340,20 @@ still validate published destinations. The archive move preserves all release dates and links; generated whitespace changes align existing series with the shared formatter so later generation remains conflict-free. -The current local setup, release-metadata, dependency-update, scientific, -benchmark, and performance tooling remains consumer-owned. Shared toolchain -setup, updates, and other maintenance adoption belong in later PRs. Notebook -tooling remains outside this repository's current scope. +Setup, tool verification, managed execution, and updates now use the published +shared CLI. Consumer declarations stay in `.python-version`, `rust-toolchain.toml`, +and `pyproject.toml`; update recipes retain coupled Cargo exclusions and explicit +dev synchronization. `tests/test_toolchain_integration.py` exercises the actual +recipes with recording update boundaries and verifies native managed execution. +The shared CLI also owns release metadata and version checks, Markdown line +checks, and Semgrep fixture validation. `tests/test_maintenance_integration.py` +checks the actual release selectors, preserved scientific evidence, DOI policy, +and recipe forwarding. `tests/test_cargo_update_integration.py` executes native +Cargo upgrades against a disposable local registry; Python updates have a +matching real-uv fixture in the toolchain tests. Common parser, transaction, +Markdown, and fixture regressions belong to the shared package. Scientific, +benchmark, and performance tooling remains consumer-owned. Notebook tooling +remains outside scope. The same pinned release owns opt-in CodeRabbit review orchestration through `research-repo-tools review branch --base=REF` and `review uncommitted`. @@ -368,13 +389,9 @@ preview an annotation without creating a tag. | `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_summaries.py` | Preserve every local case summary, bind provenance, and resolve saved report inputs after cleanup | -| `check_docs_version_sync.py` | Verify versioned documentation links and snippets stay synchronized | | `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 | | `release_baseline.py` | Inventory full Criterion suites and validate complete raw release baselines before packaging | | `subprocess_utils.py` | Safe subprocess wrappers for git commands | -| `update_cargo_tool_pins.py` | Reconcile repository-owned Cargo and active uv tool pins with installed versions | -| `update_python_dev_pins.py` | Resolve and advance exact Python development-tool pins through uv | -| `update_release_version.py` | Transactionally update deterministic release-version metadata | See `docs/RELEASING.md` for the full release workflow. diff --git a/scripts/check_docs_version_sync.py b/scripts/check_docs_version_sync.py deleted file mode 100644 index 412c176..0000000 --- a/scripts/check_docs_version_sync.py +++ /dev/null @@ -1,435 +0,0 @@ -"""Check release-version references against the Cargo package version.""" - -import argparse -import os -import re -import sys -import tomllib -from dataclasses import dataclass -from datetime import date -from enum import StrEnum -from pathlib import Path -from typing import TypeGuard - -SKIP_DIRS = frozenset( - { - ".git", - ".mypy_cache", - ".pytest_cache", - ".ruff_cache", - ".tmp_pycache", - ".venv", - "archive", - "archives", - "target", - "tests", - } -) -SKIP_MARKDOWN_FILES = frozenset({"CHANGELOG.md"}) - - -type ParsedObject = dict[str, object] - - -def _is_parsed_object(value: object) -> TypeGuard[ParsedObject]: - """Return true when a parsed TOML value is an object with string keys.""" - return isinstance(value, dict) and all(isinstance(key, str) for key in value) - - -def _require_parsed_object(value: object, context: str) -> ParsedObject: - if not _is_parsed_object(value): - msg = f"{context} is not a TOML object" - raise TypeError(msg) - return value - - -def _read_toml(path: Path) -> ParsedObject: - data: object = tomllib.loads(path.read_text(encoding="utf-8")) - return _require_parsed_object(data, str(path)) - - -def _require_table(data: ParsedObject, key: str, path: Path) -> ParsedObject: - table = data.get(key) - if not _is_parsed_object(table): - msg = f"{path} is missing a [{key}] table" - raise TypeError(msg) - return table - - -def _require_string(data: ParsedObject, key: str, context: str) -> str: - value = data.get(key) - if not isinstance(value, str): - msg = f"{context} is missing a string {key}" - raise TypeError(msg) - return value - - -@dataclass(frozen=True, slots=True) -class PackageInfo: - """Cargo package identity that defines the expected release version.""" - - name: str - version: str - - -@dataclass(frozen=True, slots=True) -class PythonProjectInfo: - """Python support-package identity used to locate its uv lock entry.""" - - name: str - version: str - - -class ReferenceKind(StrEnum): - """A release surface whose version must match Cargo.toml.""" - - CARGO_LOCK = "Cargo.lock root package" - BENCHMARK_CURRENT_TAG = "benchmark workflow current tag" - CITATION = "CITATION.cff version" - DEPENDENCY_SNIPPET = "documentation dependency snippet" - PYPROJECT = "pyproject.toml project" - README_TAG_LINK = "README tag-pinned link" - UV_LOCK = "uv.lock editable package" - - -@dataclass(frozen=True, slots=True) -class VersionReference: - """A parsed release-version reference with source location.""" - - path: Path - line: int - version: str - kind: ReferenceKind - text: str - - -@dataclass(frozen=True, slots=True) -class VersionMismatch: - """A release-version reference that does not match Cargo.toml.""" - - reference: VersionReference - package: PackageInfo - - -def read_cargo_package_info(cargo_toml: Path) -> PackageInfo: - """Read the authoritative Cargo package name and version.""" - package = _require_table(_read_toml(cargo_toml), "package", cargo_toml) - return PackageInfo( - name=_require_string(package, "name", f"{cargo_toml} [package]"), - version=_require_string(package, "version", f"{cargo_toml} [package]"), - ) - - -def read_python_project_info(pyproject_toml: Path) -> PythonProjectInfo: - """Read the Python support-package name and version.""" - project = _require_table(_read_toml(pyproject_toml), "project", pyproject_toml) - return PythonProjectInfo( - name=_require_string(project, "name", f"{pyproject_toml} [project]"), - version=_require_string(project, "version", f"{pyproject_toml} [project]"), - ) - - -def toml_table_key_line(path: Path, table_name: str, key: str) -> int: - """Return the unique key line within one TOML table.""" - current_table: str | None = None - key_re = re.compile(rf"^{re.escape(key)}\s*=") - for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): - stripped = line.strip() - if stripped.startswith("[") and stripped.endswith("]"): - current_table = stripped.strip("[]") - elif current_table == table_name and key_re.match(stripped): - return line_number - msg = f"{path} [{table_name}] is missing {key}" - raise TypeError(msg) - - -def _version_reference(path: Path, line: int, version: str, kind: ReferenceKind) -> VersionReference: - lines = path.read_text(encoding="utf-8").splitlines() - if not 1 <= line <= len(lines): - msg = f"{path} has no line {line} for {kind}" - raise TypeError(msg) - return VersionReference(path=path, line=line, version=version, kind=kind, text=lines[line - 1].strip()) - - -def _package_entries(path: Path) -> list[ParsedObject]: - packages = _read_toml(path).get("package") - if not isinstance(packages, list): - msg = f"{path} is missing [[package]] entries" - raise TypeError(msg) - entries: list[ParsedObject] = [] - for index, package in enumerate(packages, start=1): - entries.append(_require_parsed_object(package, f"{path} [[package]] entry {index}")) - return entries - - -def _array_table_key_line(path: Path, table_name: str, table_index: int, key: str) -> int: - current_index = -1 - in_target_table = False - key_re = re.compile(rf"^{re.escape(key)}\s*=") - for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): - stripped = line.strip() - if stripped == f"[[{table_name}]]": - current_index += 1 - in_target_table = current_index == table_index - elif stripped.startswith("[["): - in_target_table = False - elif in_target_table and key_re.match(stripped): - return line_number - msg = f"{path} [[{table_name}]] entry {table_index + 1} is missing {key}" - raise TypeError(msg) - - -def _single_package_reference( - path: Path, entries: list[ParsedObject], candidate_indices: list[int], package_name: str, kind: ReferenceKind -) -> VersionReference: - if len(candidate_indices) != 1: - msg = f"{path} must contain exactly one {kind} named {package_name!r}; found {len(candidate_indices)}" - raise TypeError(msg) - index = candidate_indices[0] - version = _require_string(entries[index], "version", f"{path} [[package]] entry {index + 1}") - line = _array_table_key_line(path, "package", index, "version") - return _version_reference(path, line, version, kind) - - -def cargo_lock_reference(path: Path, package: PackageInfo) -> VersionReference: - """Return the local root-package version reference from Cargo.lock.""" - entries = _package_entries(path) - candidate_indices = [index for index, entry in enumerate(entries) if entry.get("name") == package.name and "source" not in entry] - return _single_package_reference(path, entries, candidate_indices, package.name, ReferenceKind.CARGO_LOCK) - - -def pyproject_reference(path: Path, project: PythonProjectInfo) -> VersionReference: - """Return the Python project version reference from pyproject.toml.""" - line = toml_table_key_line(path, "project", "version") - return _version_reference(path, line, project.version, ReferenceKind.PYPROJECT) - - -def uv_lock_reference(path: Path, project: PythonProjectInfo) -> VersionReference: - """Return the editable support-package version reference from uv.lock.""" - entries = _package_entries(path) - candidate_indices: list[int] = [] - for index, entry in enumerate(entries): - source = entry.get("source") - if entry.get("name") == project.name and _is_parsed_object(source) and isinstance(source.get("editable"), str): - candidate_indices.append(index) - return _single_package_reference(path, entries, candidate_indices, project.name, ReferenceKind.UV_LOCK) - - -_CITATION_VERSION_RE = re.compile(r"^version:\s*(?P['\"]?)(?P[0-9A-Za-z][0-9A-Za-z.+-]*)(?P=quote)\s*(?:#.*)?$") -_CITATION_DATE_RE = re.compile(r"^date-released:\s*(?P['\"]?)(?P\d{4}-\d{2}-\d{2})(?P=quote)\s*(?:#.*)?$") - - -def citation_reference(path: Path) -> VersionReference: - """Return the top-level CFF software version reference.""" - references: list[VersionReference] = [] - for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): - if not line.startswith("version:"): - continue - match = _CITATION_VERSION_RE.fullmatch(line) - if match is None: - msg = f"{path}:{line_number}: top-level version must be a non-empty scalar" - raise TypeError(msg) - references.append(_version_reference(path, line_number, match.group("version"), ReferenceKind.CITATION)) - if len(references) != 1: - msg = f"{path} must contain exactly one top-level version; found {len(references)}" - raise TypeError(msg) - return references[0] - - -def citation_release_date(path: Path) -> tuple[int, str]: - """Return the unique top-level CFF release date and its line.""" - matches: list[tuple[int, str]] = [] - for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): - if not line.startswith("date-released:"): - continue - match = _CITATION_DATE_RE.fullmatch(line) - if match is None: - msg = f"{path}:{line_number}: top-level date-released must use YYYY-MM-DD" - raise TypeError(msg) - value = match.group("date") - try: - date.fromisoformat(value) - except ValueError as exc: - msg = f"{path}:{line_number}: invalid date-released {value!r}" - raise TypeError(msg) from exc - matches.append((line_number, value)) - if len(matches) != 1: - msg = f"{path} must contain exactly one top-level date-released; found {len(matches)}" - raise TypeError(msg) - return matches[0] - - -def changelog_release_date(path: Path, version: str) -> tuple[int, str] | None: - """Return the unique generated release-heading date for *version*, if present.""" - if not path.is_file(): - return None - heading_re = re.compile(rf"^## \[v?{re.escape(version)}\] - (?P\d{{4}}-\d{{2}}-\d{{2}})$") - changelog_matches: list[tuple[int, str]] = [] - for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): - match = heading_re.fullmatch(line) - if match is not None: - changelog_matches.append((line_number, match.group("date"))) - if not changelog_matches: - return None - if len(changelog_matches) != 1: - msg = f"{path} must contain exactly one release heading for {version}; found {len(changelog_matches)}" - raise TypeError(msg) - return changelog_matches[0] - - -def _validate_release_date_sync(root: Path, package: PackageInfo) -> None: - """Require CFF and generated changelog to use the same UTC release date.""" - changelog = root / "CHANGELOG.md" - changelog_match = changelog_release_date(changelog, package.version) - if changelog_match is None: - return - citation = root / "CITATION.cff" - citation_line, citation_date = citation_release_date(citation) - changelog_line, changelog_date = changelog_match - if citation_date != changelog_date: - msg = ( - f"release date mismatch: {citation}:{citation_line} has {citation_date}, " - f"but {changelog}:{changelog_line} has {changelog_date}; both must use the generated UTC release date" - ) - raise TypeError(msg) - - -def iter_active_markdown_files(root: Path) -> list[Path]: - """Return active Markdown files, excluding archives, fixtures, and generated history.""" - markdown_files: list[Path] = [] - for dirpath, dirnames, filenames in os.walk(root): - dirnames[:] = [dirname for dirname in dirnames if not (set((Path(dirpath) / dirname).relative_to(root).parts) & SKIP_DIRS)] - markdown_files.extend(Path(dirpath) / filename for filename in filenames if filename.endswith(".md") and filename not in SKIP_MARKDOWN_FILES) - return sorted(markdown_files) - - -def dependency_regex(package_name: str) -> re.Pattern[str]: - """Build the dependency-snippet matcher for one Cargo package.""" - escaped_name = re.escape(package_name) - return re.compile(rf'(?[^"]+)"|\{{[^}}]*version\s*=\s*"(?P[^"]+)"[^}}]*\}})') - - -def _dependency_references(path: Path, package_name: str) -> list[VersionReference]: - dependency_re = dependency_regex(package_name) - references: list[VersionReference] = [] - for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): - for match in dependency_re.finditer(line): - version = match.group("plain") or match.group("table") - references.append( - VersionReference( - path=path, - line=line_number, - version=version, - kind=ReferenceKind.DEPENDENCY_SNIPPET, - text=line.strip(), - ) - ) - return references - - -README_TAG_LINK_RE = re.compile( - r"https://(?:github\.com/acgetchell/la-stack/(?:blob|raw|tree)/|raw\.githubusercontent\.com/acgetchell/la-stack/)" - r"(?:v(?P[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)" - r"|(?P[0-9a-f]{7,40}))(?=/|$|[^0-9A-Za-z._+-])" -) -README_BENCHMARK_ASSET_PATH_PREFIX = "/docs/assets/bench/" - - -def readme_tag_link_is_benchmark_asset(match: re.Match[str]) -> bool: - """Return true when a tag-pinned README URL names a generated benchmark asset.""" - return match.string.startswith(README_BENCHMARK_ASSET_PATH_PREFIX, match.end()) - - -def _readme_tag_references(path: Path) -> list[VersionReference]: - references: list[VersionReference] = [] - for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): - references.extend( - VersionReference( - path, - line_number, - match.group("version") or match.group("revision"), - ReferenceKind.README_TAG_LINK, - line.strip(), - ) - for match in README_TAG_LINK_RE.finditer(line) - if not readme_tag_link_is_benchmark_asset(match) - ) - return references - - -_BENCHMARK_CURRENT_TAG_RE = re.compile( - r"just performance-(?:github-assets|local-non-exact|release)\s+v" - r"(?P[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)(?=\s|`)" -) - - -def _benchmark_current_tag_references(path: Path) -> list[VersionReference]: - references: list[VersionReference] = [] - for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): - references.extend( - VersionReference(path, line_number, match.group("version"), ReferenceKind.BENCHMARK_CURRENT_TAG, line.strip()) - for match in _BENCHMARK_CURRENT_TAG_RE.finditer(line) - ) - return references - - -def _version_references(root: Path, package: PackageInfo) -> list[VersionReference]: - pyproject_path = root / "pyproject.toml" - project = read_python_project_info(pyproject_path) - references = [ - cargo_lock_reference(root / "Cargo.lock", package), - pyproject_reference(pyproject_path, project), - uv_lock_reference(root / "uv.lock", project), - citation_reference(root / "CITATION.cff"), - ] - for path in iter_active_markdown_files(root): - references.extend(_dependency_references(path, package.name)) - references.extend(_benchmark_current_tag_references(path)) - references.extend(_readme_tag_references(root / "README.md")) - return references - - -def find_version_mismatches(root: Path) -> list[VersionMismatch]: - """Return release-version references that differ from Cargo.toml.""" - - package = read_cargo_package_info(root / "Cargo.toml") - _validate_release_date_sync(root, package) - return [VersionMismatch(reference=reference, package=package) for reference in _version_references(root, package) if reference.version != package.version] - - -def main(argv: list[str] | None = None) -> int: - """Check release-version references against the Cargo package version.""" - parser = argparse.ArgumentParser( - prog="check-docs-version-sync", - description="Check release-version references against Cargo.toml.", - ) - parser.add_argument( - "root", - nargs="?", - default=Path.cwd(), - type=Path, - help="Repository root to check (default: current directory).", - ) - root = parser.parse_args(argv).root.resolve() - try: - mismatches = find_version_mismatches(root) - except (OSError, TypeError, tomllib.TOMLDecodeError) as error: - print(f"Could not check release-version synchronization: {error}", file=sys.stderr) - return 1 - - if not mismatches: - return 0 - - print("Release-version references are out of sync with Cargo.toml:", file=sys.stderr) - for mismatch in mismatches: - reference = mismatch.reference - rel_path = reference.path.relative_to(root) - print( - f" {rel_path}:{reference.line}: {reference.kind} found {reference.version}, expected {mismatch.package.version}: {reference.text}", - file=sys.stderr, - ) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/check_markdown_lines.py b/scripts/check_markdown_lines.py deleted file mode 100644 index 94a5744..0000000 --- a/scripts/check_markdown_lines.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Check raw Markdown line lengths using Unicode characters in every locale.""" - -import argparse -import sys -from pathlib import Path - -MAX_LINE_LENGTH = 160 - - -def main() -> int: - """Check UTF-8 files, preserving the Markdown recipe's table exemption.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("files", nargs="+", type=Path) - args = parser.parse_args() - failed = False - for path in args.files: - try: - with path.open(encoding="utf-8") as source: - for line_number, line in enumerate(source, start=1): - # Text mode normalizes CRLF; whitespace still counts toward the limit. - line = line.removesuffix("\n") - if not line.startswith("|") and len(line) > MAX_LINE_LENGTH: - print(f"{path}:{line_number}: line length {len(line)} exceeds {MAX_LINE_LENGTH}", file=sys.stderr) - failed = True - except (OSError, UnicodeError) as error: - print(f"{path}: {error}", file=sys.stderr) - failed = True - if failed: - print("Markdown raw line-length check failed.", file=sys.stderr) - return int(failed) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/check_semgrep_fixtures.py b/scripts/check_semgrep_fixtures.py deleted file mode 100644 index 082befe..0000000 --- a/scripts/check_semgrep_fixtures.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Validate repository-owned Semgrep fixture annotations.""" - -import collections -import json -import os -import re -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import TypeGuard - -RULE_ANNOTATION = re.compile(r"\bruleid:\s*([A-Za-z0-9_.-]+(?:\s*,\s*[A-Za-z0-9_.-]+)*)") - - -type ParsedObject = dict[str, object] - - -@dataclass(frozen=True, slots=True) -class SemgrepResults: - """Validated subset of Semgrep JSON needed by fixture checks.""" - - results: tuple[ParsedObject, ...] - - -def _is_parsed_object(value: object) -> TypeGuard[ParsedObject]: - return isinstance(value, dict) and all(isinstance(key, str) for key in value) - - -def _path_argument(argv: list[str]) -> Path | None: - if len(argv) <= 1: - print("Missing required path argument: sys.argv[1]", file=sys.stderr) - return None - - path = Path(argv[1]) - if not path.exists(): - print(f"path argument does not exist: {path}", file=sys.stderr) - return None - if not path.is_file(): - print(f"path argument is not a file: {path}", file=sys.stderr) - return None - if not os.access(path, os.R_OK): - print(f"path argument is not readable: {path}", file=sys.stderr) - return None - return path - - -def _semgrep_results() -> SemgrepResults | None: - semgrep_json = os.environ.get("SEMGREP_JSON") - if semgrep_json is None: - print("Missing required SEMGREP_JSON environment variable", file=sys.stderr) - return None - try: - data: object = json.loads(semgrep_json) - except json.JSONDecodeError as error: - print(f"Invalid JSON in SEMGREP_JSON: {error}", file=sys.stderr) - return None - - if not _is_parsed_object(data): - print("Invalid SEMGREP_JSON shape: expected a JSON object", file=sys.stderr) - return None - results = data.get("results") - if not isinstance(results, list): - print("Invalid SEMGREP_JSON shape: expected 'results' to be a list", file=sys.stderr) - return None - - parsed_results: list[ParsedObject] = [] - malformed_results: list[str] = [] - for index, result in enumerate(results): - if _is_parsed_object(result): - parsed_results.append(result) - else: - malformed_results.append(f"result {index} is not an object") - - if malformed_results: - print("Invalid SEMGREP_JSON shape:", file=sys.stderr) - for malformed in malformed_results: - print(f" {malformed}", file=sys.stderr) - return None - - return SemgrepResults(results=tuple(parsed_results)) - - -type ExpectedFinding = tuple[str, int] -type ActualFinding = tuple[str, int, int] - - -def _expected_findings(path: Path) -> collections.Counter[ExpectedFinding]: - expected: collections.Counter[ExpectedFinding] = collections.Counter() - lines = path.read_text(encoding="utf-8").splitlines() - for line_number, line in enumerate(lines, start=1): - for match in RULE_ANNOTATION.finditer(line): - finding_line = line_number + 1 - while finding_line <= len(lines): - candidate = lines[finding_line - 1].strip() - if candidate and not candidate.startswith("```"): - break - finding_line += 1 - expected.update((rule_id.strip(), finding_line) for rule_id in match.group(1).split(",") if rule_id.strip()) - return expected - - -def _actual_findings(semgrep: SemgrepResults) -> tuple[ActualFinding, ...] | None: - actual: list[ActualFinding] = [] - malformed_results: list[str] = [] - for index, result in enumerate(semgrep.results): - check_id = result.get("check_id") - start = result.get("start") - end = result.get("end") - start_line = start.get("line") if _is_parsed_object(start) else None - end_line = end.get("line") if _is_parsed_object(end) else None - if not isinstance(check_id, str): - malformed_results.append(f"result {index} is missing string field 'check_id'") - if not isinstance(start_line, int) or isinstance(start_line, bool) or start_line < 1: - malformed_results.append(f"result {index} is missing positive integer field 'start.line'") - if not isinstance(end_line, int) or isinstance(end_line, bool) or end_line < 1: - malformed_results.append(f"result {index} is missing positive integer field 'end.line'") - if ( - isinstance(start_line, int) - and not isinstance(start_line, bool) - and start_line >= 1 - and isinstance(end_line, int) - and not isinstance(end_line, bool) - and end_line >= 1 - and end_line < start_line - ): - malformed_results.append(f"result {index} has end.line {end_line} before start.line {start_line}") - if ( - isinstance(check_id, str) - and isinstance(start_line, int) - and not isinstance(start_line, bool) - and start_line >= 1 - and isinstance(end_line, int) - and not isinstance(end_line, bool) - and end_line >= start_line - ): - actual.append((check_id, start_line, end_line)) - - if not malformed_results: - return tuple(actual) - - print("Invalid SEMGREP_JSON shape:", file=sys.stderr) - for malformed in malformed_results: - print(f" {malformed}", file=sys.stderr) - return None - - -def _finding_mismatches( - expected: collections.Counter[ExpectedFinding], - actual: tuple[ActualFinding, ...], -) -> tuple[str, ...]: - unmatched_actual = list(actual) - mismatches: list[str] = [] - - for (rule_id, line), expected_count in sorted(expected.items()): - for _ in range(expected_count): - match_index = min( - ( - index - for index, (actual_rule_id, start_line, end_line) in enumerate(unmatched_actual) - if actual_rule_id == rule_id and start_line <= line <= end_line - ), - key=lambda index: unmatched_actual[index][2], - default=None, - ) - if match_index is None: - mismatches.append(f"{rule_id} at line {line}: expected finding not reported") - else: - unmatched_actual.pop(match_index) - - for rule_id, start_line, end_line in sorted(unmatched_actual): - span = str(start_line) if start_line == end_line else f"{start_line}-{end_line}" - mismatches.append(f"{rule_id} at lines {span}: unexpected finding") - - return tuple(mismatches) - - -def main() -> int: - """Compare expected fixture annotations with the supplied Semgrep results.""" - path = _path_argument(sys.argv) - if path is None: - return 1 - - expected = _expected_findings(path) - - semgrep = _semgrep_results() - if semgrep is None: - return 1 - - actual = _actual_findings(semgrep) - if actual is None: - return 1 - - mismatches = _finding_mismatches(expected, actual) - if not mismatches: - return 0 - - print(f"Semgrep fixture mismatch in {path}", file=sys.stderr) - for mismatch in mismatches: - print(f" {mismatch}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/tests/test_cargo_update_integration.py b/scripts/tests/test_cargo_update_integration.py new file mode 100644 index 0000000..a686b2b --- /dev/null +++ b/scripts/tests/test_cargo_update_integration.py @@ -0,0 +1,108 @@ +"""Real managed Cargo updates against a disposable local sparse registry.""" + +import hashlib +import io +import json +import shutil +import sys +import tarfile +import threading +import tomllib +from functools import partial +from http.server import HTTPServer, SimpleHTTPRequestHandler +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from subprocess_utils import run_safe_command + +if TYPE_CHECKING: + from collections.abc import Iterator + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def crate(registry: Path, name: str, version: str) -> dict[str, object]: + """Create one deterministic registry candidate for native Cargo resolution.""" + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as archive: + for path, contents in { + "Cargo.toml": f'[package]\nname="{name}"\nversion="{version}"\nedition="2024"\n', + "src/lib.rs": "pub const VALUE: u8 = 1;\n", + }.items(): + payload = contents.encode() + member = tarfile.TarInfo(f"{name}-{version}/{path}") + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + payload = buffer.getvalue() + destination = registry / "crates" / name / version / "download" + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(payload) + return {"name": name, "vers": version, "deps": [], "cksum": hashlib.sha256(payload).hexdigest(), "features": {}, "yanked": False} + + +@pytest.fixture +def consumer(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]: + """Keep actual tool declarations and recipes; substitute only registry inputs.""" + root = tmp_path / "cargo consumer" + root.mkdir() + for name in ("pyproject.toml", "uv.lock", ".python-version", "rust-toolchain.toml"): + shutil.copyfile(REPO_ROOT / name, root / name) + # Python upgrades have their own real-uv integration test. The aggregate still + # executes the actual Cargo adapter here without contacting public registries. + (root / "recipes.just").write_text( + f"set allow-duplicate-recipes\nimport '{(REPO_ROOT / 'justfile').as_posix()}'\nupdate-python-dependencies:\n", + encoding="utf-8", + ) + dependencies = tomllib.loads((REPO_ROOT / "Cargo.toml").read_text(encoding="utf-8"))["dependencies"] + versions = {name: dependencies[name]["version"] for name in ("num-bigint", "num-rational", "num-traits")} + registry = tmp_path / "registry" + registry.mkdir() + server = HTTPServer(("127.0.0.1", 0), partial(SimpleHTTPRequestHandler, directory=str(registry))) + host = f"http://127.0.0.1:{server.server_port}" + index = registry / "index" + index.mkdir() + (index / "config.json").write_text(json.dumps({"dl": host + "/crates/{crate}/{version}/download"}), encoding="utf-8") + for name, version in versions.items(): + candidates = [version, "99.0.0"] + path = index / name[:2] / name[2:4] / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("".join(json.dumps(crate(registry, name, candidate)) + "\n" for candidate in candidates), encoding="utf-8") + (root / ".cargo").mkdir() + (root / ".cargo/config.toml").write_text(f'[registries.fixture]\nindex="sparse+{host}/index/"\n', encoding="utf-8") + (root / "src").mkdir() + (root / "src/lib.rs").write_text("pub const VALUE: u8 = 1;\n", encoding="utf-8") + requirements = "".join(f'{name} = {{ version="{version}", registry="fixture" }}\n' for name, version in versions.items()) + (root / "Cargo.toml").write_text('[package]\nname="update-fixture"\nversion="0.1.0"\nedition="2024"\n[dependencies]\n' + requirements, encoding="utf-8") + monkeypatch.setenv("UV_NO_SYNC", "1") + monkeypatch.setenv("UV_PROJECT_ENVIRONMENT", sys.prefix) + monkeypatch.delenv("CARGO_NET_OFFLINE", raising=False) + monkeypatch.delenv("CARGO_REGISTRIES_FIXTURE_INDEX", raising=False) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield root + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +@pytest.mark.parametrize("recipe", ["update-cargo-dependencies", "update-dependencies"]) +def test_native_updates_advance_eligible_dependencies_and_preserve_coupled_requirements(consumer: Path, recipe: str) -> None: + """Cargo upgrade and cargo update run through the installed checked toolchain.""" + before = tomllib.loads((consumer / "Cargo.toml").read_text(encoding="utf-8"))["dependencies"] + result = run_safe_command( + "just", + ["--justfile", str(consumer / "recipes.just"), "--working-directory", str(consumer), recipe], + cwd=consumer, + timeout=120, + ) + assert result.returncode == 0 + after = tomllib.loads((consumer / "Cargo.toml").read_text(encoding="utf-8"))["dependencies"] + assert after["num-bigint"] == before["num-bigint"] + assert after["num-rational"] == before["num-rational"] + assert after["num-traits"]["version"] == "99.0.0" + lock = tomllib.loads((consumer / "Cargo.lock").read_text(encoding="utf-8")) + assert next(package["version"] for package in lock["package"] if package["name"] == "num-traits") == "99.0.0" diff --git a/scripts/tests/test_changelog_integration.py b/scripts/tests/test_changelog_integration.py index daad9bc..bb3afd0 100644 --- a/scripts/tests/test_changelog_integration.py +++ b/scripts/tests/test_changelog_integration.py @@ -20,7 +20,7 @@ def create_consumer(root: Path, source: Path) -> Path: """Use real configuration and policy with a stable synthetic package identity.""" - for name in ("pyproject.toml", "cliff.toml", "changelog-rumdl.toml", "README.md"): + for name in ("pyproject.toml", "cliff.toml", "changelog-rumdl.toml", "README.md", ".python-version", "rust-toolchain.toml"): shutil.copyfile(source / name, root / name) (root / "Cargo.toml").write_text('[package]\nname = "la-stack"\nversion = "0.4.6"\n', encoding="utf-8") run_git_command(["init", "--quiet"], cwd=root) @@ -257,7 +257,9 @@ def test_consumer_recipes_forward_cli_arguments_and_keep_metadata_separate() -> for args, expected in commands.items(): result = run_safe_command("just", ["--dry-run", *args], cwd=REPO_ROOT) assert expected in result.stderr - assert "uv run --locked --group dev research-repo-tools" in result.stderr + assert "research-repo-tools" in result.stderr + if "generate" in expected: + assert "research-repo-tools toolchain run -- research-repo-tools changelog generate" in result.stderr assert "update-release-version" not in result.stderr @@ -269,7 +271,7 @@ def test_preview_recipe_executes_explicit_tag_and_date_without_publishing(consum (consumer / "CHANGELOG.md").write_text("# Changelog\n\n## [0.4.5] - 2000-01-02\n\n- Retained.\n", encoding="utf-8") wrapper = consumer / "recipes.just" wrapper.write_text( - f"set allow-duplicate-recipes\nimport '{(REPO_ROOT / 'justfile').as_posix()}'\n_ensure-git-cliff:\n\n_ensure-rumdl:\n\npython-sync:\n", + f"set allow-duplicate-recipes\nimport '{(REPO_ROOT / 'justfile').as_posix()}'\ntools-check:\n\npython-sync:\n", encoding="utf-8", ) before = markdown_bytes(consumer) @@ -349,7 +351,7 @@ def test_fixture_tags_remain_valid_after_the_source_release_version_advances(tmp """A release update in the checkout cannot invalidate synthetic tag scenarios.""" source = tmp_path / "advanced-source" source.mkdir() - for name in ("pyproject.toml", "cliff.toml", "changelog-rumdl.toml", "README.md"): + for name in ("pyproject.toml", "cliff.toml", "changelog-rumdl.toml", "README.md", ".python-version", "rust-toolchain.toml"): shutil.copyfile(REPO_ROOT / name, source / name) manifest = source / "pyproject.toml" text = manifest.read_text(encoding="utf-8") diff --git a/scripts/tests/test_check_docs_version_sync.py b/scripts/tests/test_check_docs_version_sync.py deleted file mode 100644 index 45ef97a..0000000 --- a/scripts/tests/test_check_docs_version_sync.py +++ /dev/null @@ -1,249 +0,0 @@ -"""Tests for documentation and package-version synchronization checks.""" - -from typing import TYPE_CHECKING - -import pytest - -import check_docs_version_sync - -if TYPE_CHECKING: - from pathlib import Path - - -_CARGO_TOML = '[package]\nname = "other-crate"\nversion = "1.2.3"' -_VERSION = "1.2.3" - - -def _write_project( - root: Path, - *, - metadata_version: str = _VERSION, - readme: str | None = None, -) -> None: - readme_text = ( - readme - if readme is not None - else f'other-crate = "{_VERSION}"\n[doc](https://github.com/acgetchell/la-stack/blob/v{_VERSION}/README.md)\n[raw](https://raw.githubusercontent.com/acgetchell/la-stack/v{_VERSION}/README.md)\n' - ) - files = { - "Cargo.toml": f"{_CARGO_TOML}\n", - "Cargo.lock": f'version = 4\n\n[[package]]\nname = "other-crate"\nversion = "{metadata_version}"\n', - "pyproject.toml": f'[project]\nname = "other-crate-scripts"\nversion = "{metadata_version}"\n', - "uv.lock": f'version = 1\n\n[[package]]\nname = "other-crate-scripts"\nversion = "{metadata_version}"\nsource = {{ editable = "." }}\n', - "CITATION.cff": f"cff-version: 1.2.0\nversion: {metadata_version}\n", - "README.md": readme_text, - } - for filename, content in files.items(): - (root / filename).write_text(content, encoding="utf-8") - - -def test_find_version_mismatches_accepts_matching_dependency_snippets(tmp_path: Path) -> None: - _write_project( - tmp_path, - readme='other-crate = "1.2.3"\nother-crate = { version = "1.2.3", features = ["exact"] }\nla-stack = "0.4.1"', - ) - - assert check_docs_version_sync.find_version_mismatches(tmp_path) == [] - - -def test_find_version_mismatches_reports_stale_dependency_snippets(tmp_path: Path) -> None: - _write_project(tmp_path) - docs = tmp_path / "docs" - docs.mkdir() - (docs / "install.md").write_text( - 'other-crate = { version = "1.2.2", features = ["exact"] }\n', - encoding="utf-8", - ) - - mismatches = check_docs_version_sync.find_version_mismatches(tmp_path) - - assert len(mismatches) == 1 - assert mismatches[0].reference.path == docs / "install.md" - assert mismatches[0].reference.line == 1 - assert mismatches[0].reference.version == "1.2.2" - assert mismatches[0].package.name == "other-crate" - assert mismatches[0].package.version == "1.2.3" - - -def test_find_version_mismatches_handles_reordered_inline_table_keys(tmp_path: Path) -> None: - _write_project(tmp_path) - docs = tmp_path / "docs" - docs.mkdir() - install_doc = docs / "install.md" - install_doc.write_text( - 'other-crate = { features = ["exact"], version = "1.2.2" }\n', - encoding="utf-8", - ) - - mismatches = check_docs_version_sync.find_version_mismatches(tmp_path) - - assert len(mismatches) == 1 - assert mismatches[0].reference.path == install_doc - assert mismatches[0].reference.line == 1 - assert mismatches[0].reference.version == "1.2.2" - assert mismatches[0].package.name == "other-crate" - assert mismatches[0].package.version == "1.2.3" - - -def test_find_version_mismatches_reports_all_release_metadata(tmp_path: Path) -> None: - _write_project( - tmp_path, - metadata_version="1.2.2", - ) - - mismatches = check_docs_version_sync.find_version_mismatches(tmp_path) - - assert [(mismatch.reference.kind, mismatch.reference.path.name, mismatch.reference.line, mismatch.reference.version) for mismatch in mismatches] == [ - (check_docs_version_sync.ReferenceKind.CARGO_LOCK, "Cargo.lock", 5, "1.2.2"), - (check_docs_version_sync.ReferenceKind.PYPROJECT, "pyproject.toml", 3, "1.2.2"), - (check_docs_version_sync.ReferenceKind.UV_LOCK, "uv.lock", 5, "1.2.2"), - (check_docs_version_sync.ReferenceKind.CITATION, "CITATION.cff", 2, "1.2.2"), - ] - - -def test_find_version_mismatches_reports_readme_tag_links(tmp_path: Path) -> None: - _write_project( - tmp_path, - readme=( - "[doc](https://github.com/acgetchell/la-stack/blob/v1.2.2/README.md)\n" - "[raw](https://raw.githubusercontent.com/acgetchell/la-stack/v1.2.1/README.md)\n" - "[stale-commit](https://github.com/acgetchell/la-stack/blob/abc1234/README.md)\n" - "[moving](https://github.com/acgetchell/la-stack/blob/main/README.md)\n" - ), - ) - - mismatches = check_docs_version_sync.find_version_mismatches(tmp_path) - - assert [mismatch.reference.kind for mismatch in mismatches] == [check_docs_version_sync.ReferenceKind.README_TAG_LINK] * 3 - assert [mismatch.reference.line for mismatch in mismatches] == [1, 2, 3] - assert [mismatch.reference.version for mismatch in mismatches] == ["1.2.2", "1.2.1", "abc1234"] - - -def test_find_version_mismatches_ignores_plot_owned_readme_benchmark_asset_links(tmp_path: Path) -> None: - _write_project( - tmp_path, - readme=( - "[csv](https://github.com/acgetchell/la-stack/blob/v1.2.2/docs/assets/bench/result.csv)\n" - "[provenance](https://github.com/acgetchell/la-stack/blob/v1.2.2/docs/assets/bench/result.provenance.json)\n" - "[svg](https://raw.githubusercontent.com/acgetchell/la-stack/v1.2.2/docs/assets/bench/result.svg)\n" - ), - ) - - assert check_docs_version_sync.find_version_mismatches(tmp_path) == [] - - -@pytest.mark.parametrize("tag", ["v1.2.3.4", "v1.2.3.extra", "v1.2.3_suffix"]) -def test_readme_tag_references_reject_longer_non_semver_tags(tmp_path: Path, tag: str) -> None: - readme = tmp_path / "README.md" - readme.write_text( - f"[invalid](https://github.com/acgetchell/la-stack/blob/{tag}/README.md)\n", - encoding="utf-8", - ) - - assert check_docs_version_sync._readme_tag_references(readme) == [] - - -@pytest.mark.parametrize("recipe", ["performance-github-assets", "performance-local-non-exact", "performance-release"]) -def test_find_version_mismatches_reports_stale_benchmark_current_tags(tmp_path: Path, recipe: str) -> None: - _write_project(tmp_path) - docs = tmp_path / "docs" - docs.mkdir() - workflows = docs / "workflows.md" - workflows.write_text( - f"| Release workflow | `just {recipe} v1.2.2 v1.2.1` |\n" - "```bash\njust performance-local-non-exact v1.2.3 v1.2.2\n```\n" - "Historical v1.2.1 behavior remains documented.\n", - encoding="utf-8", - ) - - mismatches = check_docs_version_sync.find_version_mismatches(tmp_path) - - assert len(mismatches) == 1 - assert mismatches[0].reference.kind is check_docs_version_sync.ReferenceKind.BENCHMARK_CURRENT_TAG - assert mismatches[0].reference.path == workflows - assert mismatches[0].reference.line == 1 - assert mismatches[0].reference.version == "1.2.2" - - -def test_benchmark_current_tag_references_ignore_baselines_and_historical_prose(tmp_path: Path) -> None: - benchmarking = tmp_path / "BENCHMARKING.md" - benchmarking.write_text( - "just performance-release v1.2.3 v1.2.2\nThe v1.2.2 harness compares against v1.2.1.\n", - encoding="utf-8", - ) - - references = check_docs_version_sync._benchmark_current_tag_references(benchmarking) - - assert [(reference.line, reference.version) for reference in references] == [(1, "1.2.3")] - - -@pytest.mark.parametrize( - "version", - ["1.2.3", "1.2.3-rc.1", "1.2.3+build.7", "1.2.3-rc.1+build.7"], -) -def test_readme_tag_references_accept_semver_suffixes(tmp_path: Path, version: str) -> None: - readme = tmp_path / "README.md" - readme.write_text( - f"[tagged](https://github.com/acgetchell/la-stack/blob/v{version}/README.md)\n", - encoding="utf-8", - ) - - references = check_docs_version_sync._readme_tag_references(readme) - - assert [(reference.line, reference.version) for reference in references] == [(1, version)] - - -@pytest.mark.parametrize("archive_dir", ["archive", "archives/changelog"]) -def test_find_version_mismatches_ignores_historical_docs_and_test_fixtures(tmp_path: Path, archive_dir: str) -> None: - _write_project(tmp_path) - archive = tmp_path / "docs" / archive_dir - archive.mkdir(parents=True) - fixtures = tmp_path / "tests" / "fixtures" - fixtures.mkdir(parents=True) - stale_snippet = 'other-crate = "0.1.0"\njust performance-release v0.1.0 v0.0.9\n' - (tmp_path / "CHANGELOG.md").write_text(stale_snippet, encoding="utf-8") - (archive / "old.md").write_text(stale_snippet, encoding="utf-8") - (fixtures / "example.md").write_text(stale_snippet, encoding="utf-8") - - assert check_docs_version_sync.find_version_mismatches(tmp_path) == [] - - -def test_find_version_mismatches_rejects_missing_editable_uv_package(tmp_path: Path) -> None: - _write_project(tmp_path) - (tmp_path / "uv.lock").write_text( - 'version = 1\n\n[[package]]\nname = "other-crate-scripts"\nversion = "1.2.3"\nsource = { registry = "https://pypi.org/simple" }\n', - encoding="utf-8", - ) - - with pytest.raises(TypeError, match=r"exactly one uv\.lock editable package"): - check_docs_version_sync.find_version_mismatches(tmp_path) - - -def test_find_version_mismatches_rejects_malformed_citation_version(tmp_path: Path) -> None: - _write_project(tmp_path) - (tmp_path / "CITATION.cff").write_text('cff-version: 1.2.0\nversion: "\n', encoding="utf-8") - - with pytest.raises(TypeError, match=r"CITATION\.cff:2: top-level version"): - check_docs_version_sync.find_version_mismatches(tmp_path) - - -def test_main_supports_help(capsys: pytest.CaptureFixture[str]) -> None: - with pytest.raises(SystemExit, match="0"): - check_docs_version_sync.main(["--help"]) - - assert "Repository root to check" in capsys.readouterr().out - - -def test_main_rejects_extra_positional_arguments() -> None: - with pytest.raises(SystemExit, match="2"): - check_docs_version_sync.main(["one", "two"]) - - -def test_release_date_must_match_generated_changelog(tmp_path: Path) -> None: - _write_project(tmp_path) - citation = tmp_path / "CITATION.cff" - citation.write_text(citation.read_text(encoding="utf-8") + "date-released: 2026-07-12\n", encoding="utf-8") - (tmp_path / "CHANGELOG.md").write_text("# Changelog\n\n## [1.2.3] - 2026-07-13\n", encoding="utf-8") - - with pytest.raises(TypeError, match="release date mismatch"): - check_docs_version_sync.find_version_mismatches(tmp_path) diff --git a/scripts/tests/test_check_markdown_lines.py b/scripts/tests/test_check_markdown_lines.py deleted file mode 100644 index 4787218..0000000 --- a/scripts/tests/test_check_markdown_lines.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Regression coverage for locale-independent Markdown line-length checks.""" - -import os -import subprocess -import sys -from pathlib import Path - -import pytest - -CHECKER = Path(__file__).resolve().parents[1] / "check_markdown_lines.py" - - -@pytest.mark.parametrize( - ("content", "diagnostic"), - [ - (("a" * 159 + "—\n").encode(), ""), - (("a" * 159 + "🗺\r\n").encode(), ""), - (("a" * 159 + "—").encode(), ""), - (("a" * 160 + "—\n").encode(), ":1: line length 161 exceeds 160"), - (b"a" * 161, ":1: line length 161 exceeds 160"), - (b"a" * 159 + b" \n", ":1: line length 161 exceeds 160"), - (b"|" + b"a" * 200 + b"\n", ""), - (b"short\r\n" + b"a" * 161 + b"\r\n", ":2: line length 161 exceeds 160"), - (b"invalid UTF-8: \xff\n", "decode"), - ], - ids=["unicode-limit", "emoji-crlf", "no-final-newline", "unicode-too-long", "ascii-too-long", "trailing-spaces", "table", "line-number", "invalid-utf8"], -) -def test_raw_line_limit_under_c_locale(tmp_path: Path, content: bytes, diagnostic: str) -> None: - """Count UTF-8 characters under the locale that exposed the Windows failure.""" - markdown = tmp_path / "document with spaces.md" - markdown.write_bytes(content) - environment = os.environ.copy() - environment["LC_ALL"] = "C" - result = subprocess.run( # noqa: S603 - fixed local checker and test-owned input. - [sys.executable, str(CHECKER), str(markdown)], - check=False, - capture_output=True, - encoding="utf-8", - env=environment, - timeout=30, - ) - - assert result.stdout == "" - if diagnostic: - assert result.returncode == 1 - assert str(markdown) in result.stderr - assert diagnostic in result.stderr - assert "Markdown raw line-length check failed." in result.stderr - else: - assert result.returncode == 0 - assert result.stderr == "" diff --git a/scripts/tests/test_check_semgrep_fixtures.py b/scripts/tests/test_check_semgrep_fixtures.py deleted file mode 100644 index 8416fe0..0000000 --- a/scripts/tests/test_check_semgrep_fixtures.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Tests for Semgrep fixture-annotation validation.""" - -import json -from typing import TYPE_CHECKING - -import check_semgrep_fixtures - -if TYPE_CHECKING: - from pathlib import Path - - import pytest - - -def _result(check_id: str, line: int, end_line: int | None = None) -> dict[str, object]: - return {"check_id": check_id, "start": {"line": line}, "end": {"line": line if end_line is None else end_line}} - - -def test_semgrep_results_parses_valid_result_objects(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv( - "SEMGREP_JSON", - json.dumps({"results": [_result("rust.foo", 2), _result("rust.bar", 4)]}), - ) - - results = check_semgrep_fixtures._semgrep_results() - - assert results is not None - assert [result["check_id"] for result in results.results] == ["rust.foo", "rust.bar"] - - -def test_semgrep_results_rejects_malformed_result_objects(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: - monkeypatch.setenv("SEMGREP_JSON", json.dumps({"results": [_result("rust.foo", 2), "bad"]})) - - results = check_semgrep_fixtures._semgrep_results() - - assert results is None - assert "result 1 is not an object" in capsys.readouterr().err - - -def test_main_accepts_matching_annotations(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: - fixture = tmp_path / "fixture.rs" - fixture.write_text( - "// ruleid: rust.foo, rust.bar\n// ruleid: rust.foo\n", - encoding="utf-8", - ) - monkeypatch.setenv( - "SEMGREP_JSON", - json.dumps({"results": [_result("rust.foo", 2), _result("rust.bar", 2), _result("rust.foo", 3)]}), - ) - monkeypatch.setattr(check_semgrep_fixtures.sys, "argv", ["check_semgrep_fixtures.py", str(fixture)]) - - rc = check_semgrep_fixtures.main() - - assert rc == 0 - captured = capsys.readouterr() - assert captured.out == "" - assert captured.err == "" - - -def test_main_reports_missing_check_id(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: - fixture = tmp_path / "fixture.rs" - fixture.write_text("// ruleid: rust.foo\n", encoding="utf-8") - monkeypatch.setenv("SEMGREP_JSON", json.dumps({"results": [{}]})) - monkeypatch.setattr(check_semgrep_fixtures.sys, "argv", ["check_semgrep_fixtures.py", str(fixture)]) - - rc = check_semgrep_fixtures.main() - - assert rc == 1 - captured = capsys.readouterr() - assert "missing string field 'check_id'" in captured.err - assert "missing positive integer field 'start.line'" in captured.err - assert "missing positive integer field 'end.line'" in captured.err - - -def test_main_rejects_reversed_result_span( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - capsys: pytest.CaptureFixture[str], -) -> None: - fixture = tmp_path / "fixture.rs" - fixture.write_text("// ruleid: rust.foo\nbad();\n", encoding="utf-8") - monkeypatch.setenv("SEMGREP_JSON", json.dumps({"results": [_result("rust.foo", 4, 2)]})) - monkeypatch.setattr(check_semgrep_fixtures.sys, "argv", ["check_semgrep_fixtures.py", str(fixture)]) - - assert check_semgrep_fixtures.main() == 1 - assert "result 0 has end.line 2 before start.line 4" in capsys.readouterr().err - - -def test_main_rejects_findings_at_wrong_lines_even_when_rule_counts_match( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - capsys: pytest.CaptureFixture[str], -) -> None: - fixture = tmp_path / "fixture.rs" - fixture.write_text("// ruleid: rust.foo\nbad_one();\n// ruleid: rust.foo\nbad_two();\n", encoding="utf-8") - monkeypatch.setenv( - "SEMGREP_JSON", - json.dumps({"results": [_result("rust.foo", 2), _result("rust.foo", 5)]}), - ) - monkeypatch.setattr(check_semgrep_fixtures.sys, "argv", ["check_semgrep_fixtures.py", str(fixture)]) - - rc = check_semgrep_fixtures.main() - - assert rc == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert "rust.foo at line 4: expected finding not reported" in captured.err - assert "rust.foo at lines 5: unexpected finding" in captured.err - - -def test_main_matches_overlapping_spans_by_earliest_end_line( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = tmp_path / "fixture.rs" - fixture.write_text("// ruleid: rust.foo\nbad_one();\n// ruleid: rust.foo\nbad_two();\n", encoding="utf-8") - monkeypatch.setenv( - "SEMGREP_JSON", - json.dumps({"results": [_result("rust.foo", 2, 4), _result("rust.foo", 2)]}), - ) - monkeypatch.setattr(check_semgrep_fixtures.sys, "argv", ["check_semgrep_fixtures.py", str(fixture)]) - - assert check_semgrep_fixtures.main() == 0 - - -def test_main_matches_markdown_finding_after_blank_line_and_code_fence( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = tmp_path / "fixture.md" - fixture.write_text("\n\n```bash\nbad-command\n```\n", encoding="utf-8") - monkeypatch.setenv("SEMGREP_JSON", json.dumps({"results": [_result("docs.foo", 4)]})) - monkeypatch.setattr(check_semgrep_fixtures.sys, "argv", ["check_semgrep_fixtures.py", str(fixture)]) - - assert check_semgrep_fixtures.main() == 0 diff --git a/scripts/tests/test_justfile_discoverability.py b/scripts/tests/test_justfile_discoverability.py index c6a055e..926ffec 100644 --- a/scripts/tests/test_justfile_discoverability.py +++ b/scripts/tests/test_justfile_discoverability.py @@ -1,18 +1,12 @@ """Regression tests for the Just recipe surface.""" import json -import os -import re import shlex import shutil -import stat import subprocess from pathlib import Path from typing import Any -import pytest - -import update_cargo_tool_pins from subprocess_utils import run_cargo_command, run_safe_command REPO_ROOT = Path(__file__).resolve().parents[2] @@ -69,7 +63,8 @@ def test_exact_benchmark_package_excludes_peer_libraries() -> None: def test_release_runs_each_suite_once_and_reuses_peer_measurements() -> None: baseline = run_just("--dry-run", "bench-save-baseline", "v0.4.5", "all") # Execute the real recipe's shell branching while replacing only Cargo. - script = 'cargo() { printf "%s\\n" "$*"; }\n' + baseline.stderr + runner = run_just("--evaluate", "_run").stdout.strip() + script = 'cargo() { printf "%s\\n" "$*"; }\n' + baseline.stderr.replace(runner + " ", "") baseline_run = run_safe_command("bash", ["--noprofile", "--norc", "-euc", script], cwd=REPO_ROOT) baseline_commands = [shlex.split(line) for line in baseline_run.stdout.splitlines()] recipes = just_recipes() @@ -79,7 +74,8 @@ def test_release_runs_each_suite_once_and_reuses_peer_measurements() -> None: for dependency in current["dependencies"]: recipe = recipes[dependency["recipe"]] assert not recipe["dependencies"] - current_commands.extend(shlex.split("".join(line)) for line in recipe["body"]) + commands = run_just("--dry-run", dependency["recipe"]).stderr + current_commands.extend(shlex.split(line.removeprefix(runner + " ")) for line in commands.splitlines()) assert len(baseline_commands) == len(current_commands) == 2 baseline_comparison, baseline_exact = baseline_commands @@ -94,175 +90,6 @@ def test_release_runs_each_suite_once_and_reuses_peer_measurements() -> None: assert current_comparison[current_comparison.index("--") + 1 :] == ["la_stack", "--noplot"] -def write_fake_uv(directory: Path, version_output: str, *, windows_lookup_output: str | None = None) -> None: - """Write a Bash uv shim and an optional conflicting native Windows shim.""" - real_uv = shutil.which("uv") - assert real_uv is not None - posix_shim = directory / "uv" - posix_shim.write_text( - f"""#!/bin/sh -if [ "$1" = "--version" ]; then - printf '%s\\n' {shlex.quote(version_output)} -else - exec {shlex.quote(real_uv)} "$@" -fi -""", - encoding="utf-8", - newline="\n", - ) - posix_shim.chmod(posix_shim.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - - if os.name == "nt": - native_output = windows_lookup_output or version_output - if re.fullmatch(r"[A-Za-z0-9 ._-]+", native_output) is None: - msg = f"unsupported native uv version fixture output: {native_output!r}" - raise ValueError(msg) - windows_shim = directory / "uv.cmd" - real_uv_command = subprocess.list2cmdline([real_uv]) - script = "\r\n".join( - ( - "@echo off", - 'if "%~1"=="--version" (', - f" echo({native_output}", - " exit /b 0", - ")", - f"{real_uv_command} %*", - "", - ) - ) - windows_shim.write_text( - script, - encoding="utf-8", - newline="", - ) - - -def test_uv_backed_helpers_reuse_pinned_guard() -> None: - """Local uv consumers should share one exact-version implementation.""" - recipes = just_recipes() - ensure_uv_body = json.dumps(recipes["_ensure-uv"]["body"]) - setup_tools_body = json.dumps(recipes["setup-tools"]["body"]) - - assert "uv --version" in ensure_uv_body - assert "uv_version" in ensure_uv_body - assert [dependency["recipe"] for dependency in recipes["_ensure-uv"]["dependencies"]] == ["_ensure-uv-available"] - assert "verify_tool_version uv" in setup_tools_body - for name in ("_ensure-actionlint", "_ensure-shellcheck", "_ensure-shfmt", "_ensure-yamllint"): - dependencies = {dependency["recipe"] for dependency in recipes[name]["dependencies"]} - assert "_ensure-uv" in dependencies, name - - -def test_uv_guard_reports_expected_and_actual_versions(tmp_path: Path) -> None: - """A mismatched uv executable should fail with actionable version details.""" - fake_bin = tmp_path / "bin" - fake_bin.mkdir() - write_fake_uv(fake_bin, "uv 9.9.9") - - environment = os.environ.copy() - environment["CARGO_HOME"] = str(tmp_path / "cargo") - environment["PATH"] = f"{fake_bin}{os.pathsep}{environment['PATH']}" - - expected = run_just("--evaluate", "uv_version").stdout.strip() - result = run_just("_ensure-uv", check=False, env=environment) - - assert result.returncode != 0 - assert f"version '9.9.9', expected '{expected}'" in result.stderr - - -@pytest.mark.parametrize( - "output", - ["uv 9.9.9 using runtime 3.14.0", "uv version unknown", "uv 9.9.9-beta.1", "uv 9.9.9.1", "uv release-9.9.9"], -) -def test_stable_uv_preflight_rejects_unstorable_versions(tmp_path: Path, output: str) -> None: - """Update preflights should reject uv versions the pin reconciler cannot store.""" - fake_bin = tmp_path / "bin" - fake_bin.mkdir() - write_fake_uv(fake_bin, output, windows_lookup_output="uv 7.7.7") - environment = os.environ.copy() - environment["PATH"] = f"{fake_bin}{os.pathsep}{environment['PATH']}" - - result = run_just("_ensure-stable-uv-version", check=False, env=environment) - - assert result.returncode != 0 - assert "must report exactly one stable X.Y.Z version" in result.stderr - - -def test_stable_uv_preflight_accepts_newer_stable_version(tmp_path: Path) -> None: - """Update reconciliation may advance the repository's active uv pin.""" - fake_bin = tmp_path / "bin" - fake_bin.mkdir() - write_fake_uv(fake_bin, "uv 9.9.9") - environment = os.environ.copy() - environment["PATH"] = f"{fake_bin}{os.pathsep}{environment['PATH']}" - - result = run_just("_ensure-stable-uv-version", check=False, env=environment) - - assert result.returncode == 0 - - -def test_update_workflow_composes_scoped_dependency_and_tool_updates() -> None: - """Update recipes should cover repo state without touching unrelated global tools.""" - recipes = just_recipes() - update_dependencies = [dependency["recipe"] for dependency in recipes["update"]["dependencies"]] - - assert update_dependencies == [ - "_ensure-cargo-install-update", - "_ensure-stable-uv-version", - "update-dependencies", - "update-cargo-tools", - ] - - aggregate_result = run_just("--dry-run", "update") - aggregate_update = aggregate_result.stdout + aggregate_result.stderr - cargo_upgrade = "cargo upgrade --incompatible allow --exclude num-bigint --exclude num-rational" - assert aggregate_update.index("command -v cargo-install-update") < aggregate_update.index(cargo_upgrade) - assert aggregate_update.index("--check-uv-version") < aggregate_update.index(cargo_upgrade) - - dependency_result = run_just("--dry-run", "update-dependencies") - dependency_update = dependency_result.stdout + dependency_result.stderr - dependency_preflights = [dependency["recipe"] for dependency in recipes["update-dependencies"]["dependencies"]] - assert dependency_preflights[:2] == ["_ensure-cargo-edit", "_ensure-stable-uv-version"] - assert dependency_update.index("cargo upgrade --version") < dependency_update.index(cargo_upgrade) - assert dependency_update.index("--check-uv-version") < dependency_update.index(cargo_upgrade) - assert cargo_upgrade in dependency_update - assert "cargo update" in dependency_update - assert "uv run --locked update-python-dev-pins" in dependency_update - assert "uv lock --upgrade" in dependency_update - assert dependency_update.index("uv run --locked update-python-dev-pins") < dependency_update.index("uv lock --upgrade") - assert "uv sync --locked --group dev" in dependency_update - assert "cargo install-update --all" not in dependency_update - assert "uv tool upgrade" not in dependency_update - - tool_result = run_just("--dry-run", "update-cargo-tools") - tool_update = tool_result.stdout + tool_result.stderr - assert "command -v cargo-install-update" in tool_update - assert "cargo install-update --locked" in tool_update - assert tool_update.index("--check-uv-version") < tool_update.index("cargo install-update --locked") - assert "update-cargo-tool-pins" in tool_update - assert "cargo install-update --all" not in tool_update - assert "uv tool upgrade" not in tool_update - package_block = re.search(r"packages=\(\n(?P.*?)\n\)", tool_update, re.DOTALL) - assert package_block is not None - updated_packages = set(re.findall(r"^\s+([a-z0-9-]+)$", package_block.group("packages"), re.MULTILINE)) - assert updated_packages == set(update_cargo_tool_pins.PIN_TO_PACKAGE.values()) - - -def test_setup_tools_installs_and_verifies_cargo_update_provider() -> None: - """A clean setup must provide the updater used by the update workflow.""" - body = json.dumps(just_recipes()["setup-tools"]["body"]) - - assert "cargo install --locked cargo-update --version" in body - assert "verify_tool_version cargo-install-update" in body - - -def test_managed_tool_pins_exist_once_in_root_justfile() -> None: - """Every managed Cargo package and uv should map to one root Just pin.""" - justfile_text = (REPO_ROOT / "justfile").read_text(encoding="utf-8") - - for pin in update_cargo_tool_pins.PIN_TO_TOOL: - assert len(re.findall(rf'(?m)^{re.escape(pin)}\s*:=\s*"[^"]+"\s*$', justfile_text)) == 1 - - def test_ci_enforces_full_python_fixture_lint_policy() -> None: """Canonical CI should lint fixtures without narrowing the Ruff configuration.""" recipes = just_recipes() diff --git a/scripts/tests/test_maintenance_integration.py b/scripts/tests/test_maintenance_integration.py new file mode 100644 index 0000000..1e7d7d1 --- /dev/null +++ b/scripts/tests/test_maintenance_integration.py @@ -0,0 +1,136 @@ +"""Published maintenance CLI integration with la-stack's actual consumer policy.""" + +import os +import re +import shutil +import sys +import tomllib +from pathlib import Path + +import pytest +from research_repo_tools.cli import main + +from subprocess_utils import run_git_command, run_safe_command + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture +def consumer(tmp_path: Path) -> Path: + """Copy release inputs and real documentation, without live repository state.""" + files = run_git_command(["ls-files", "-co", "--exclude-standard", "-z"], cwd=REPO_ROOT).stdout.split("\0") + metadata = {"Cargo.toml", "Cargo.lock", "pyproject.toml", "uv.lock", "CITATION.cff", ".python-version", "rust-toolchain.toml"} + for name in files: + source = REPO_ROOT / name + if not name or not source.is_file() or (source.suffix != ".md" and name not in metadata and name != "benches/comparison/Cargo.toml"): + continue + destination = tmp_path / name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + return tmp_path + + +def snapshot(root: Path) -> dict[str, bytes]: + """Capture exact source bytes for previews, failures, and retained evidence.""" + return {path.relative_to(root).as_posix(): path.read_bytes() for path in root.rglob("*") if path.is_file()} + + +def release_args(root: Path, *args: str) -> list[str]: + """Prepare the next patch only in a disposable consumer.""" + current = tomllib.loads((root / "Cargo.toml").read_text(encoding="utf-8"))["package"]["version"] + major, minor, patch = current.split(".") + return ["--root", str(root), "release", "update", f"v{major}.{minor}.{int(patch) + 1}", "--previous-release", f"v{current}", "--date", "2026-10-01", *args] + + +def test_release_preview_and_update_preserve_scientific_evidence_and_dependency_versions(consumer: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Exercise every real release selector while retaining measured and historical data.""" + before = snapshot(consumer) + assert main(["--root", str(consumer), "release", "check"]) == 0 + assert main(release_args(consumer, "--dry-run")) == 0 + assert snapshot(consumer) == before + assert main(release_args(consumer)) == 0 + after = snapshot(consumer) + for name, contents in before.items(): + if name.startswith(("docs/archive/", "docs/archives/", "docs/performance/")) or name in {"docs/performance.md", "CHANGELOG.md"}: + assert after[name] == contents, name + original_cargo = tomllib.loads(before["Cargo.lock"].decode()) + updated_cargo = tomllib.loads(after["Cargo.lock"].decode()) + assert [entry for entry in updated_cargo["package"] if "source" in entry] == [entry for entry in original_cargo["package"] if "source" in entry] + original_uv = tomllib.loads(before["uv.lock"].decode()) + updated_uv = tomllib.loads(after["uv.lock"].decode()) + assert [entry for entry in updated_uv["package"] if "registry" in entry["source"]] == [ + entry for entry in original_uv["package"] if "registry" in entry["source"] + ] + artifact = r"https://[^\s)]+/docs/assets/bench/[^\s)]+" + assert re.findall(artifact, after["README.md"].decode()) == re.findall(artifact, before["README.md"].decode()) + assert after["docs/BENCHMARKING.md"] == before["docs/BENCHMARKING.md"] + assert after["benches/comparison/Cargo.toml"] == before["benches/comparison/Cargo.toml"] + # Metadata preparation deliberately leaves old notes intact. The release gate + # remains red until dated notes for the target are generated. + assert main(["--root", str(consumer), "release", "check"]) == 1 + assert "latest generated changelog release" in capsys.readouterr().err + target = tomllib.loads(after["Cargo.toml"].decode())["package"]["version"] + changelog = consumer / "CHANGELOG.md" + changelog.write_text(f"# Changelog\n\n## [{target}] - 2026-10-01\n\n- Fixture release notes.\n", encoding="utf-8") + assert main(["--root", str(consumer), "release", "check", "--final-release"]) == 0 + + +@pytest.mark.parametrize("file", ["CITATION.cff", "README.md", "REFERENCES.md"]) +def test_concept_doi_policy_rejects_drift_before_publication(consumer: Path, file: str) -> None: + path = consumer / file + path.write_text(path.read_text(encoding="utf-8").replace("10.5281/zenodo.18158926", "10.5281/zenodo.99999999"), encoding="utf-8") + before = snapshot(consumer) + assert main(["--root", str(consumer), "release", "check"]) == 1 + assert main(release_args(consumer)) == 1 + assert snapshot(consumer) == before + + +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") + before = snapshot(consumer) + assert main(release_args(consumer, "--dry-run")) == 1 + assert snapshot(consumer) == before + + +def test_update_recipe_runs_the_installed_cli_with_preview_arguments(consumer: Path) -> None: + """Execute the imported adapter with managed tools, skipping sync and gh lookup.""" + wrapper = consumer / "recipes.just" + wrapper.write_text( + f"set allow-duplicate-recipes\nimport '{(REPO_ROOT / 'justfile').as_posix()}'\npython-sync:\n\n_ensure-gh:\n", + encoding="utf-8", + ) + before = snapshot(consumer) + args = release_args(consumer, "--dry-run")[4:] + result = run_safe_command( + "just", + ["--justfile", str(wrapper), "--working-directory", str(consumer), "update-version", *args], + cwd=consumer, + env=os.environ | {"UV_NO_SYNC": "1", "UV_PROJECT_ENVIRONMENT": sys.prefix}, + ) + assert "Cargo.toml" in result.stdout + assert snapshot(consumer) == before + + +def test_markdown_recipe_uses_the_published_checker_and_retains_character_limit(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Verify the consumer boundary with Unicode, tables, spaces, and path quoting.""" + path = tmp_path / "document with spaces.md" + path.write_text("é" * 160 + "\n|" + "x" * 200 + "\n", encoding="utf-8") + assert main(["--root", str(tmp_path), "docs", "check-lines", str(path)]) == 0 + path.write_text("é" * 161 + "\n", encoding="utf-8") + assert main(["--root", str(tmp_path), "docs", "check-lines", str(path)]) == 1 + assert "line length 161 exceeds 160" in capsys.readouterr().err + dry_run = run_safe_command("just", ["--dry-run", "markdown-check"], cwd=REPO_ROOT) + assert 'research-repo-tools docs check-lines "${files[@]}"' in dry_run.stderr + + +def test_semgrep_adapter_uses_real_consumer_rules_and_fixtures() -> None: + """The shared checker validates the supplied namespace, including hidden workflows.""" + config = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))["tool"]["research-repo-tools"]["semgrep"] + assert config["config"] == "semgrep.yaml" + assert config["fixtures"] == "tests/semgrep" + assert config["namespace"] == "la-stack." + result = run_safe_command("just", ["--dry-run", "semgrep-test"], cwd=REPO_ROOT) + assert "research-repo-tools semgrep check-fixtures" in result.stderr diff --git a/scripts/tests/test_release_baseline.py b/scripts/tests/test_release_baseline.py index a332517..578c857 100644 --- a/scripts/tests/test_release_baseline.py +++ b/scripts/tests/test_release_baseline.py @@ -222,7 +222,9 @@ def test_workflow_requires_preflight_and_isolates_publication_permissions() -> N assert "run: just test-bench-inputs" in preparation assert "run: just bench-release-inventory" in preparation assert "continue-on-error" not in preparation - assert preparation.index("- name: Require fresh release preflight") < preparation.index("- name: Install Rust toolchain") + assert preparation.index("- name: Require fresh release preflight") < preparation.index("- name: Set up declared tools without caches") + assert "uses: ./.github/actions/setup-tools" in preparation + assert "cache: false" in preparation steps = [ "Prepare release benchmarks", "Save comparative Criterion baseline", diff --git a/scripts/tests/test_review_integration.py b/scripts/tests/test_review_integration.py index 1099188..ad34e1b 100644 --- a/scripts/tests/test_review_integration.py +++ b/scripts/tests/test_review_integration.py @@ -103,7 +103,7 @@ def test_local_scopes_do_not_require_a_remote(consumer: Path, stub: Path, args: def test_service_failures_and_interruption_status_reach_just(consumer: Path, stub: Path, monkeypatch: pytest.MonkeyPatch, status: int) -> None: monkeypatch.setenv("REVIEW_STUB_STATUS", str(status)) result = recipe(consumer, "review-uncommitted") - assert result.returncode != 0 + assert result.returncode == status assert f"exit code {status}" in result.stderr assert "fixture diagnostic" in result.stderr diff --git a/scripts/tests/test_security_integration.py b/scripts/tests/test_security_integration.py new file mode 100644 index 0000000..f9f3bf9 --- /dev/null +++ b/scripts/tests/test_security_integration.py @@ -0,0 +1,52 @@ +"""Check that the consumer Gitleaks exception preserves secret detection.""" + +import hashlib +from pathlib import Path + +import pytest + +from subprocess_utils import run_safe_command + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +@pytest.mark.parametrize( + ("relative", "credential", "status"), + [ + ("scripts/bench_compare.py", False, 0), + ("scripts/bench_compare.py", True, 1), + ("scripts/other.py", False, 1), + ], +) +def test_historical_alias_exception_is_limited_to_its_assignment_and_path(tmp_path: Path, relative: str, *, credential: bool, status: int) -> None: + """Native scans still catch a synthetic key and the alias outside its owner.""" + source = tmp_path / relative + source.parent.mkdir(parents=True, exist_ok=True) + identifier = "V0_4_3_API_COMPATIBILITY" + contents = f"_{identifier} = {identifier}\n" + if credential: + token = hashlib.sha256(b"public Gitleaks regression fixture").hexdigest() + contents += "api_" + f'key = "{token}"\n' + source.write_text(contents, encoding="utf-8") + result = run_safe_command( + "uv", + [ + "run", + "--locked", + "--no-sync", + "research-repo-tools", + "toolchain", + "run", + "--", + "gitleaks", + "dir", + "--config", + str(REPO_ROOT / ".gitleaks.toml"), + "--redact", + "--ignore-gitleaks-allow", + str(tmp_path), + ], + cwd=REPO_ROOT, + check=False, + ) + assert result.returncode == status diff --git a/scripts/tests/test_toolchain_integration.py b/scripts/tests/test_toolchain_integration.py new file mode 100644 index 0000000..f75c936 --- /dev/null +++ b/scripts/tests/test_toolchain_integration.py @@ -0,0 +1,180 @@ +"""Consumer toolchain and update integration with the installed shared release.""" + +import json +import os +import shlex +import shutil +import sys +import tomllib +import zipfile +from pathlib import Path +from typing import TYPE_CHECKING, cast + +import pytest +from research_repo_tools.cli import main + +from subprocess_utils import run_safe_command + +if TYPE_CHECKING: + import subprocess + +REPO_ROOT = Path(__file__).resolve().parents[2] +CHECKED = ["run", "--locked", "--no-sync", "--no-python-downloads", "research-repo-tools", "toolchain", "run", "--"] +UPDATER = ["run", "--locked", "--only-group", "tooling", "--inexact", "research-repo-tools"] +UV_UPDATE = ["run", "--no-config", "--no-sync", "--no-python-downloads", "research-repo-tools", "deps", "update-uv"] +TOOL_UPDATE = [*UPDATER, "toolchain", "upgrade"] +SETUP = ["run", "--locked", "--managed-python", "--only-group", "tooling", "research-repo-tools", "setup"] +CARGO_UPDATE = [ + [*UPDATER, "toolchain", "run", "--", "cargo", "upgrade", "--incompatible", "allow", "--exclude", "num-bigint", "--exclude", "num-rational"], + [*UPDATER, "toolchain", "run", "--", "cargo", "update"], +] +PYTHON_UPDATE = [ + [*UPDATER, "deps", "update-python"], + ["lock", "--upgrade"], + [*CHECKED, "uv", "sync", "--locked", "--managed-python", "--group", "dev"], +] +WORKFLOWS = { + "update": [UV_UPDATE, TOOL_UPDATE, SETUP, *CARGO_UPDATE, *PYTHON_UPDATE], + "update-tools": [UV_UPDATE, TOOL_UPDATE, SETUP], + "update-dependencies": [*CARGO_UPDATE, *PYTHON_UPDATE], + "update-cargo-dependencies": CARGO_UPDATE, + "update-python-dependencies": PYTHON_UPDATE, + "update-python-deps": PYTHON_UPDATE, + "update-cargo-tools": [TOOL_UPDATE], + "update-uv": [UV_UPDATE], +} + + +@pytest.fixture +def consumer(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Use the actual recipes and declarations; record updater process boundaries.""" + root = tmp_path / "consumer with spaces" + root.mkdir() + for name in ("pyproject.toml", "uv.lock", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".python-version"): + shutil.copyfile(REPO_ROOT / name, root / name) + (root / "recipes.just").write_text( + f"set allow-duplicate-recipes\nimport '{(REPO_ROOT / 'justfile').as_posix()}'\n_ensure-gh:\n\n_ensure-jq:\n", + encoding="utf-8", + ) + recorder = root / "record calls.py" + recorder.write_text( + "import json, os, pathlib, sys\n" + "log = pathlib.Path('calls.jsonl')\n" + "with log.open('a', encoding='utf-8') as stream: stream.write(json.dumps(sys.argv[1:]) + '\\n')\n" + "if len(log.read_text(encoding='utf-8').splitlines()) == int(os.environ.get('UPDATE_FAIL_STEP', '0')):\n" + " print('fixture update failed', file=sys.stderr)\n" + " sys.exit(23)\n", + encoding="utf-8", + ) + shim = root / "uv" + shim.write_text(f'#!/bin/sh\nexec {shlex.quote(Path(sys.executable).as_posix())} {shlex.quote(recorder.as_posix())} "$@"\n', encoding="utf-8", newline="\n") + shim.chmod(0o755) + monkeypatch.setenv("PATH", f"{root}{os.pathsep}{os.environ['PATH']}") + monkeypatch.delenv("UPDATE_FAIL_STEP", raising=False) + return root + + +def invoke(root: Path, recipe: str) -> tuple[subprocess.CompletedProcess[str], list[list[str]]]: + """Run Just's actual dependency sequencing without live upgrades.""" + result = run_safe_command("just", ["--justfile", str(root / "recipes.just"), "--working-directory", str(root), recipe], cwd=root, check=False) + calls = [cast("list[str]", json.loads(line)) for line in (root / "calls.jsonl").read_text(encoding="utf-8").splitlines()] + return result, calls + + +@pytest.mark.parametrize("recipe", WORKFLOWS) +def test_update_recipes_sequence_and_preserve_coupled_exclusions(consumer: Path, recipe: str) -> None: + """Direct and aggregate updates share boundaries, order, exclusions, and dev sync.""" + result, calls = invoke(consumer, recipe) + assert result.returncode == 0, result.stderr + assert calls == WORKFLOWS[recipe] + + +@pytest.mark.parametrize("step", range(1, 9)) +def test_update_failure_stops_later_steps(consumer: Path, monkeypatch: pytest.MonkeyPatch, step: int) -> None: + monkeypatch.setenv("UPDATE_FAIL_STEP", str(step)) + result, calls = invoke(consumer, "update") + assert result.returncode == 23 + assert "fixture update failed" in result.stderr + assert calls == WORKFLOWS["update"][:step] + + +def test_declared_tools_are_verified_and_cargo_edit_runs_from_managed_store(capsys: pytest.CaptureFixture[str]) -> None: + """Exercise the installed public CLI on each native CI platform, without installs.""" + assert main(["--root", str(REPO_ROOT), "toolchain", "check", "--json"]) == 0 + report = json.loads(capsys.readouterr().out) + assert all(status["ok"] for status in report) + result = run_safe_command("uv", [*CHECKED, "cargo", "upgrade", "--version"], cwd=REPO_ROOT) + declared = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))["tool"]["research-repo-tools"]["toolchain"]["cargo"] + names = {"cargo-edit": "cargo-edit-upgrade", "taplo-cli": "taplo"} + for package, expected in declared.items(): + status = next(item for item in report if item["name"] == names.get(package, package)) + assert status["actual"] == status["required"] == expected + directory = Path(status["path"]).parent + assert directory.name == "bin" + assert directory.parent.name == expected + assert directory.parent.parent.name == package + assert result.stdout.strip() == f"cargo-edit-upgrade {declared['cargo-edit']}" + # The checked runner supplies the same managed Rust to subprocesses, including native Python builds. + result = run_safe_command( + "uv", [*CHECKED, "uv", "run", "--locked", "--no-sync", "python", "-c", "import shutil; print(shutil.which('cargo'))"], cwd=REPO_ROOT + ) + rustup = next(status for status in report if status["name"] == "rustup") + assert Path(result.stdout.strip()).parent == Path(rustup["path"]).parent + + +def fixture_wheel(registry: Path, requirement: str) -> None: + """Supply offline resolution candidates; installed CLI code comes from the host.""" + name, version = requirement.split("==") + distribution = name.replace("-", "_") + info = f"{distribution}-{version}.dist-info" + with zipfile.ZipFile(registry / f"{distribution}-{version}-py3-none-any.whl", "w") as archive: + archive.writestr(f"{info}/METADATA", f"Metadata-Version: 2.3\nName: {name}\nVersion: {version}\nRequires-Python: >=3.14\n") + archive.writestr(f"{info}/WHEEL", "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n") + archive.writestr(f"{info}/RECORD", "") + + +def test_python_update_preserves_shared_pin_and_tools_with_explicit_dev_sync(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Run the published updater and real uv against consumer config and offline wheels.""" + root = tmp_path / "offline consumer" + root.mkdir() + registry = tmp_path / "wheels" + registry.mkdir() + original = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") + # Empty fixture wheels represent resolver candidates, not executable tools. + # Disable building the consumer's own scripts package in this disposable fixture. + source = original.replace("package = true", "package = false\ndefault-groups = []") + manifest = root / "pyproject.toml" + manifest.write_text(source, encoding="utf-8") + before = tomllib.loads(source) + groups = before["dependency-groups"] + for requirement in [*groups["tooling"], *(entry for entry in groups["dev"] if isinstance(entry, str)), "ruff==99.0.0"]: + fixture_wheel(registry, requirement) + for name in ("UV_PROJECT", "UV_WORKING_DIR", "UV_WORKING_DIRECTORY", "UV_CONFIG_FILE"): + monkeypatch.delenv(name, raising=False) + for name, value in { + "UV_CACHE_DIR": str(tmp_path / "cache"), + "UV_FIND_LINKS": str(registry), + "UV_NO_INDEX": "1", + "UV_OFFLINE": "1", + "UV_PYTHON": sys.executable, + "UV_PYTHON_DOWNLOADS": "never", + "UV_PROJECT_ENVIRONMENT": str(root / ".venv"), + }.items(): + monkeypatch.setenv(name, value) + + assert main(["--root", str(root), "deps", "update-python"]) == 0 + after = tomllib.loads(manifest.read_text(encoding="utf-8")) + assert "ruff==99.0.0" in after["dependency-groups"]["dev"] + assert after["dependency-groups"]["tooling"] == groups["tooling"] + assert after["tool"]["research-repo-tools"] == before["tool"]["research-repo-tools"] + assert after["tool"]["uv"] == before["tool"]["uv"] + run_safe_command("uv", ["lock", "--upgrade"], cwd=root) + lock = tomllib.loads((root / "uv.lock").read_text(encoding="utf-8")) + packages = {package["name"]: package["version"] for package in lock["package"]} + assert packages["research-repo-tools"] == "0.1.7" + assert packages["ruff"] == "99.0.0" + run_safe_command("uv", ["sync", "--locked", "--group", "dev"], cwd=root) + installed = run_safe_command( + "uv", ["run", "--locked", "--no-sync", "python", "-c", "from importlib.metadata import version; print(version('ruff'))"], cwd=root + ) + assert installed.stdout.strip() == "99.0.0" diff --git a/scripts/tests/test_update_cargo_tool_pins.py b/scripts/tests/test_update_cargo_tool_pins.py deleted file mode 100644 index df30f1e..0000000 --- a/scripts/tests/test_update_cargo_tool_pins.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Tests for atomic repository tool-pin reconciliation.""" - -import subprocess -from typing import TYPE_CHECKING, Never - -import pytest - -import update_cargo_tool_pins -from subprocess_utils import ExecutableNotFoundError - -if TYPE_CHECKING: - from pathlib import Path - - -def installed_output(*, override: tuple[str, str] | None = None) -> str: - """Return representative ``cargo install --list`` output for every managed tool.""" - versions = dict.fromkeys(update_cargo_tool_pins.PIN_TO_PACKAGE.values(), "1.2.3") - if override is not None: - versions[override[0]] = override[1] - return "".join(f"{package} v{version}:\n {package}\n" for package, version in versions.items()) - - -def justfile_text(version: str = "1.2.3") -> str: - """Return one assignment for every managed Just pin.""" - return "".join(f'{pin} := "{version}"\n' for pin in update_cargo_tool_pins.PIN_TO_TOOL) - - -def test_reconcile_pins_updates_one_changed_version_atomically(tmp_path: Path) -> None: - justfile = tmp_path / "justfile" - justfile.write_text(justfile_text(), encoding="utf-8") - - changes = update_cargo_tool_pins.reconcile_pins( - justfile, - installed_output(override=("rumdl", "2.0.0")), - "uv 1.2.3", - ) - - assert changes == {"rumdl_version": ("1.2.3", "2.0.0")} - assert 'rumdl_version := "2.0.0"' in justfile.read_text(encoding="utf-8") - assert list(tmp_path.glob(".justfile.*")) == [] - - -def test_reconcile_pins_updates_uv_version_atomically(tmp_path: Path) -> None: - justfile = tmp_path / "justfile" - justfile.write_text(justfile_text(), encoding="utf-8") - - changes = update_cargo_tool_pins.reconcile_pins(justfile, installed_output(), "uv 2.0.0") - - assert changes == {"uv_version": ("1.2.3", "2.0.0")} - assert 'uv_version := "2.0.0"' in justfile.read_text(encoding="utf-8") - assert list(tmp_path.glob(".justfile.*")) == [] - - -def test_reconcile_pins_rejects_missing_package_without_writing(tmp_path: Path) -> None: - justfile = tmp_path / "justfile" - original = justfile_text() - justfile.write_text(original, encoding="utf-8") - incomplete = installed_output().replace("rumdl v1.2.3:\n rumdl\n", "") - - with pytest.raises(ValueError, match="managed tool is not installed: rumdl"): - update_cargo_tool_pins.reconcile_pins(justfile, incomplete, "uv 1.2.3") - - assert justfile.read_text(encoding="utf-8") == original - - -def test_update_pin_text_rejects_duplicate_assignment() -> None: - duplicated = justfile_text() + 'rumdl_version := "1.2.3"\n' - installed = update_cargo_tool_pins.parse_installed_packages(installed_output()) - installed["uv"] = "1.2.3" - - with pytest.raises(ValueError, match="expected exactly one rumdl_version assignment, found 2"): - update_cargo_tool_pins.update_pin_text(duplicated, installed) - - -def test_parse_installed_packages_accepts_prerelease_with_build_metadata() -> None: - version = "1.2.3-rc.1+build.5" - - installed = update_cargo_tool_pins.parse_installed_packages(installed_output(override=("rumdl", version))) - - assert installed["rumdl"] == version - - -@pytest.mark.parametrize( - "version", - ["01.2.3", "1.02.3", "1.2.03", "1.2.3-01", "1.2.3-alpha..beta", "1.2.3+", "1.2.3+build..1"], -) -def test_parse_installed_packages_rejects_noncanonical_semver(version: str) -> None: - with pytest.raises(ValueError, match="invalid installed version for rumdl"): - update_cargo_tool_pins.parse_installed_packages(installed_output(override=("rumdl", version))) - - -def test_reconcile_pins_preserves_prerelease_with_build_metadata(tmp_path: Path) -> None: - version = "1.2.3-rc.1+build.5" - justfile = tmp_path / "justfile" - justfile.write_text(justfile_text(), encoding="utf-8") - - changes = update_cargo_tool_pins.reconcile_pins( - justfile, - installed_output(override=("rumdl", version)), - "uv 1.2.3", - ) - - assert changes == {"rumdl_version": ("1.2.3", version)} - assert f'rumdl_version := "{version}"' in justfile.read_text(encoding="utf-8").splitlines() - - -@pytest.mark.parametrize( - "output", - ["uv 1.2.3 using runtime 3.14.0", "uv version unknown", "uv 1.2.3-rc.1", "uv 1.2.3.4", "uv release-1.2.3"], -) -def test_parse_tool_version_rejects_ambiguous_missing_prerelease_or_embedded_versions(output: str) -> None: - with pytest.raises(ValueError, match="expected exactly one uv version"): - update_cargo_tool_pins.parse_tool_version(output, "uv") - - -def test_check_uv_version_reuses_pin_reconciler_parser() -> None: - with pytest.raises(ValueError, match=r"must report exactly one stable X\.Y\.Z version"): - update_cargo_tool_pins.check_uv_version("uv 9.9.9 using runtime 3.14.0") - - -def test_main_checks_captured_uv_output_without_resolving_another_executable(monkeypatch: pytest.MonkeyPatch) -> None: - def unexpected_uv_lookup(*_args: object, **_kwargs: object) -> Never: - msg = "captured uv output must not trigger another executable lookup" - raise AssertionError(msg) - - monkeypatch.setattr(update_cargo_tool_pins, "run_safe_command", unexpected_uv_lookup) - - assert update_cargo_tool_pins.main(["--check-uv-version=uv 9.9.9"]) == 0 - - -def test_main_reports_missing_cargo_without_traceback( - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - def missing_cargo(_args: list[str], **_kwargs: object) -> Never: - msg = "Required executable 'cargo' not found in PATH" - raise ExecutableNotFoundError(msg) - - monkeypatch.setattr(update_cargo_tool_pins, "run_cargo_command", missing_cargo) - - assert update_cargo_tool_pins.main([]) == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert captured.err == "failed to update tool pins: Required executable 'cargo' not found in PATH\n" - - -def test_main_reports_missing_uv_without_traceback( - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - monkeypatch.setattr( - update_cargo_tool_pins, - "run_cargo_command", - lambda _args, **_kwargs: subprocess.CompletedProcess([], 0, stdout=installed_output(), stderr=""), - ) - - def missing_uv(_command: str, _args: list[str], **_kwargs: object) -> Never: - msg = "Required executable 'uv' not found in PATH" - raise ExecutableNotFoundError(msg) - - monkeypatch.setattr(update_cargo_tool_pins, "run_safe_command", missing_uv) - - assert update_cargo_tool_pins.main([]) == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert captured.err == "failed to update tool pins: Required executable 'uv' not found in PATH\n" diff --git a/scripts/tests/test_update_python_dev_pins.py b/scripts/tests/test_update_python_dev_pins.py deleted file mode 100644 index 8a39c34..0000000 --- a/scripts/tests/test_update_python_dev_pins.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Tests for resolver-backed Python development-tool pin updates.""" - -import subprocess -import tomllib -from pathlib import Path - -import pytest - -import update_python_dev_pins - - -def project_text(*requirements: str) -> str: - """Return a minimal project with exact development-tool pins.""" - rendered = "\n".join(f' "{requirement}",' for requirement in requirements) - return f"""[project] -name = "fixture" -version = "0.1.0" -requires-python = ">=3.14" - -[dependency-groups] -dev = [ -{rendered} -] -""" - - -def test_parse_project_accepts_exact_simple_dev_pins() -> None: - python_version, pins = update_python_dev_pins.parse_project(project_text("ruff==0.16.2", "semgrep==1.172.0")) - - assert python_version == "3.14" - assert pins == [ - update_python_dev_pins.DevPin("ruff", "0.16.2"), - update_python_dev_pins.DevPin("semgrep", "1.172.0"), - ] - - -def test_actual_project_keeps_included_tooling_outside_direct_dev_updates() -> None: - text = (Path(__file__).resolve().parents[2] / "pyproject.toml").read_text(encoding="utf-8") - python_version, pins = update_python_dev_pins.parse_project(text) - groups = tomllib.loads(text)["dependency-groups"] - assert python_version == "3.14" - assert groups["tooling"] == ["research-repo-tools==0.1.7"] - assert {"include-group": "tooling"} in groups["dev"] - assert "research-repo-tools" not in {pin.name for pin in pins} - assert [f"{pin.name}=={pin.version}" for pin in pins] == [entry for entry in groups["dev"] if isinstance(entry, str)] - - -@pytest.mark.parametrize( - ("entry", "error", "diagnostic"), - [ - ('{ include-group = "missing" }', ValueError, "existing dependency group"), - ("{ include-group = 42 }", ValueError, "existing dependency group"), - ('{ include-group = "dev" }', ValueError, "other than dev"), - ('{ include-group = "tooling", extra = true }', TypeError, "strings or include-group tables"), - ("42", TypeError, "strings or include-group tables"), - ], -) -def test_parse_project_rejects_malformed_dev_entries(entry: str, error: type[Exception], diagnostic: str) -> None: - text = project_text("ruff==0.16.2").replace("dev = [", f"dev = [\n {entry},") + 'tooling = ["research-repo-tools==0.1.7"]\n' - with pytest.raises(error, match=diagnostic): - update_python_dev_pins.parse_project(text) - - -def test_parse_project_rejects_non_exact_dev_requirement() -> None: - with pytest.raises(ValueError, match=r"development-tool requirements must be exact simple pins: ruff>=0\.16"): - update_python_dev_pins.parse_project(project_text("ruff>=0.16")) - - -def test_parse_resolution_preserves_direct_order_and_ignores_transitives() -> None: - pins = [ - update_python_dev_pins.DevPin("ruff", "0.16.2"), - update_python_dev_pins.DevPin("semgrep", "1.172.0"), - ] - output = "packaging==26.3\nsemgrep==1.174.0\nruff==0.16.4\nmcp==1.29.0\n" - - assert update_python_dev_pins.parse_resolution(output, pins) == [ - update_python_dev_pins.DevPin("ruff", "0.16.4"), - update_python_dev_pins.DevPin("semgrep", "1.174.0"), - ] - - -def test_parse_resolution_rejects_missing_direct_tool() -> None: - pins = [update_python_dev_pins.DevPin("semgrep", "1.172.0")] - - with pytest.raises(ValueError, match="uv resolver output omitted direct development tool: semgrep"): - update_python_dev_pins.parse_resolution("mcp==1.29.0\n", pins) - - -@pytest.mark.parametrize("include_tooling", [False, True]) -def test_update_dev_pins_resolves_then_applies_one_exact_transaction( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - include_tooling: bool, -) -> None: - pyproject = tmp_path / "pyproject.toml" - text = project_text("ruff==0.16.2", "semgrep==1.172.0") - if include_tooling: - text = text.replace("dev = [", 'dev = [\n { include-group = "tooling" },') + 'tooling = ["research-repo-tools==0.1.7"]\n' - pyproject.write_text(text, encoding="utf-8") - calls: list[tuple[str, list[str], dict[str, object]]] = [] - - def fake_run(command: str, args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: - calls.append((command, args, kwargs)) - output = "ruff==0.16.4\nsemgrep==1.174.0\nmcp==1.29.0\n" if args[:2] == ["pip", "compile"] else "" - return subprocess.CompletedProcess([command, *args], 0, stdout=output, stderr="") - - monkeypatch.setattr(update_python_dev_pins, "run_safe_command", fake_run) - - changes = update_python_dev_pins.update_dev_pins(pyproject) - - assert changes == { - "ruff": ("0.16.2", "0.16.4"), - "semgrep": ("1.172.0", "1.174.0"), - } - assert calls[0] == ( - "uv", - [ - "pip", - "compile", - "-", - "--universal", - "--no-header", - "--no-annotate", - "--python-version", - "3.14", - ], - {"cwd": tmp_path, "input": "ruff\nsemgrep\n"}, - ) - assert calls[1] == ( - "uv", - ["add", "--dev", "--no-sync", "ruff==0.16.4", "semgrep==1.174.0"], - {"cwd": tmp_path}, - ) - - -def test_main_reports_uv_diagnostics_without_traceback( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text(project_text("semgrep==1.172.0"), encoding="utf-8") - - def failed_uv(_command: str, args: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: - raise subprocess.CalledProcessError(1, ["uv", *args], stderr="resolver conflict") - - monkeypatch.setattr(update_python_dev_pins, "run_safe_command", failed_uv) - - assert update_python_dev_pins.main(["--pyproject", str(pyproject)]) == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert captured.err == "failed to update Python development-tool pins: resolver conflict\n" diff --git a/scripts/tests/test_update_release_version.py b/scripts/tests/test_update_release_version.py deleted file mode 100644 index 57dd42f..0000000 --- a/scripts/tests/test_update_release_version.py +++ /dev/null @@ -1,226 +0,0 @@ -"""Tests for transactional release-version updates.""" - -from typing import TYPE_CHECKING - -import pytest - -import check_docs_version_sync -import update_release_version - -if TYPE_CHECKING: - from pathlib import Path - - -def _write_project(root: Path, *, metadata_version: str = "1.2.2", dependency_version: str = "1.2.2") -> None: - files = { - "Cargo.toml": f'[package]\nname = "other-crate"\nversion = "{metadata_version}"\n', - "Cargo.lock": ( - f'version = 4\n\n[[package]]\nname = "either"\nversion = "1.17.0"\n\n[[package]]\nname = "other-crate"\nversion = "{metadata_version}"\n' - ), - "pyproject.toml": f'[project]\nname = "other-crate-scripts"\nversion = "{metadata_version}"\n', - "uv.lock": (f'version = 1\n\n[[package]]\nname = "other-crate-scripts"\nversion = "{metadata_version}"\nsource = {{ editable = "." }}\n'), - "CITATION.cff": (f'cff-version: 1.2.0\nversion: {metadata_version}\ndate-released: 2026-07-13\ndoi: "10.5281/zenodo.12345"\n'), - "README.md": ( - f'other-crate = "{dependency_version}"\n' - f'other-crate = {{ version = "{dependency_version}", features = ["exact"] }}\n' - f"[doc](https://github.com/acgetchell/la-stack/blob/v{metadata_version}/README.md)\n" - f"[raw](https://raw.githubusercontent.com/acgetchell/la-stack/v{metadata_version}/README.md)\n" - f"[csv](https://github.com/acgetchell/la-stack/blob/v{metadata_version}/docs/assets/bench/result.csv)\n" - f"[provenance](https://github.com/acgetchell/la-stack/blob/v{metadata_version}/docs/assets/bench/result.provenance.json)\n" - f"[svg](https://raw.githubusercontent.com/acgetchell/la-stack/v{metadata_version}/docs/assets/bench/result.svg)\n" - "\n" - "| unchanged benchmark comparison |\n" - "\n" - ), - "CHANGELOG.md": "# Changelog\n\n## [1.2.2] - 2026-07-13\n", - } - for filename, content in files.items(): - (root / filename).write_text(content, encoding="utf-8") - docs = root / "docs" - docs.mkdir() - (docs / "BENCHMARKING.md").write_text( - "just performance-release v1.2.2 v1.2.1\nThis generates a local `v1.2.1` `vs_linalg` baseline.\nHistorical v1.2.1 behavior remains documented.\n", - encoding="utf-8", - ) - - -def _previous() -> update_release_version.ReleaseTag: - return update_release_version.parse_release_tag("v1.2.2") - - -def test_update_release_version_updates_all_current_surfaces_without_dependency_upgrades(tmp_path: Path) -> None: - _write_project(tmp_path) - - summary = update_release_version.update_release_version( - tmp_path, - "v1.2.3", - previous=_previous(), - release_date="2026-08-20", - ) - - assert summary.target.tag == "v1.2.3" - assert summary.previous.tag == "v1.2.2" - assert summary.release_date == "2026-08-20" - assert summary.changed_paths - assert 'name = "either"\nversion = "1.17.0"' in (tmp_path / "Cargo.lock").read_text(encoding="utf-8") - assert 'name = "other-crate"\nversion = "1.2.3"' in (tmp_path / "Cargo.lock").read_text(encoding="utf-8") - assert 'name = "other-crate-scripts"\nversion = "1.2.3"' in (tmp_path / "uv.lock").read_text(encoding="utf-8") - assert 'version = "1.2.3"' in (tmp_path / "Cargo.toml").read_text(encoding="utf-8") - assert 'version = "1.2.3"' in (tmp_path / "pyproject.toml").read_text(encoding="utf-8") - citation = (tmp_path / "CITATION.cff").read_text(encoding="utf-8") - assert "version: 1.2.3" in citation - assert "date-released: 2026-08-20" in citation - assert 'doi: "10.5281/zenodo.12345"' in citation - readme = (tmp_path / "README.md").read_text(encoding="utf-8") - assert 'other-crate = "1.2.3"' in readme - assert 'version = "1.2.3"' in readme - assert readme.count("v1.2.3") == 2 - assert readme.count("v1.2.2/docs/assets/bench/") == 3 - assert "| unchanged benchmark comparison |" in readme - benchmarking = (tmp_path / "docs" / "BENCHMARKING.md").read_text(encoding="utf-8") - assert "just performance-release v1.2.3 v1.2.2" in benchmarking - assert "This generates a local `v1.2.2` `vs_linalg` baseline." in benchmarking - assert "Historical v1.2.1 behavior remains documented." in benchmarking - assert "## [1.2.2] - 2026-07-13" in (tmp_path / "CHANGELOG.md").read_text(encoding="utf-8") - assert check_docs_version_sync.find_version_mismatches(tmp_path) == [] - - -def test_update_release_version_is_idempotent(tmp_path: Path) -> None: - _write_project(tmp_path) - kwargs = {"previous": _previous(), "release_date": "2026-08-20"} - - first = update_release_version.update_release_version(tmp_path, "v1.2.3", **kwargs) - second = update_release_version.update_release_version(tmp_path, "v1.2.3", **kwargs) - - assert first.changed_paths - assert second.changed_paths == () - - -def test_update_release_version_advances_existing_release_dates_together(tmp_path: Path) -> None: - _write_project(tmp_path, metadata_version="1.2.3", dependency_version="1.2.3") - citation = tmp_path / "CITATION.cff" - citation.write_text(citation.read_text(encoding="utf-8").replace("2026-07-13", "2026-08-20"), encoding="utf-8") - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text("# Changelog\n\n## [1.2.3] - 2026-08-20\n", encoding="utf-8") - benchmarking = tmp_path / "docs" / "BENCHMARKING.md" - benchmarking.write_text( - "just performance-release v1.2.3 v1.2.2\nThis generates a local `v1.2.2` `vs_linalg` baseline.\n", - encoding="utf-8", - ) - - summary = update_release_version.update_release_version( - tmp_path, - "v1.2.3", - previous=_previous(), - release_date="2026-08-21", - ) - - assert summary.changed_paths == (changelog, citation) - assert "date-released: 2026-08-21" in citation.read_text(encoding="utf-8") - assert "## [1.2.3] - 2026-08-21" in changelog.read_text(encoding="utf-8") - - -def test_select_previous_release_tag_uses_latest_stable_published_tag() -> None: - target = update_release_version.parse_release_tag("v1.3.0") - - previous = update_release_version.select_previous_release_tag( - ["v1.1.9", "v1.2.0-rc.1", "not-a-release", "v1.2.0"], - target, - ) - - assert previous.tag == "v1.2.0" - - -def test_select_previous_release_tag_ignores_already_published_target() -> None: - target = update_release_version.parse_release_tag("v1.3.0") - - previous = update_release_version.select_previous_release_tag(["v1.2.0", "v1.3.0"], target) - - assert previous.tag == "v1.2.0" - - -@pytest.mark.parametrize("target", ["1.2.3", "v1.2", "v01.2.3", "v1.2.3-rc.1"]) -def test_parse_release_tag_rejects_non_stable_tag_forms(target: str) -> None: - with pytest.raises(ValueError, match=r"stable tag in vX\.Y\.Z form"): - update_release_version.parse_release_tag(target) - - -def test_select_previous_release_tag_rejects_target_older_than_a_published_release() -> None: - target = update_release_version.parse_release_tag("v1.2.3") - - with pytest.raises(ValueError, match="older than published"): - update_release_version.select_previous_release_tag(["v1.2.3", "v1.3.0"], target) - - -def test_infer_previous_release_tag_uses_published_github_releases(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(update_release_version, "published_stable_release_tags", lambda root: ["v1.2.1", "v1.2.2"] if root == tmp_path else []) - - previous = update_release_version.infer_previous_release_tag(tmp_path, update_release_version.parse_release_tag("v1.2.3")) - - assert previous.tag == "v1.2.2" - - -def test_unexpected_version_fails_before_writing(tmp_path: Path) -> None: - _write_project(tmp_path, dependency_version="1.0.0") - originals = {path: path.read_text(encoding="utf-8") for path in tmp_path.rglob("*") if path.is_file()} - - with pytest.raises(ValueError, match="unexpected other-crate dependency version"): - update_release_version.update_release_version( - tmp_path, - "v1.2.3", - previous=_previous(), - release_date="2026-08-20", - ) - - assert {path: path.read_text(encoding="utf-8") for path in originals} == originals - - -def test_validation_failure_rolls_back_every_changed_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - _write_project(tmp_path) - originals = {path: path.read_text(encoding="utf-8") for path in tmp_path.rglob("*") if path.is_file()} - - def fail_validation(*_args: object) -> None: - msg = "simulated validation failure" - raise ValueError(msg) - - monkeypatch.setattr(update_release_version, "_validate_updated_root", fail_validation) - - with pytest.raises(ValueError, match="simulated validation failure"): - update_release_version.update_release_version( - tmp_path, - "v1.2.3", - previous=_previous(), - release_date="2026-08-20", - ) - - assert {path: path.read_text(encoding="utf-8") for path in originals} == originals - - -def test_sync_changelog_release_date_uses_citation_date(tmp_path: Path) -> None: - _write_project(tmp_path) - update_release_version.update_release_version( - tmp_path, - "v1.2.3", - previous=_previous(), - release_date="2026-08-20", - ) - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text("# Changelog\n\n## [1.2.3] - 2026-08-21\n", encoding="utf-8") - - changed, release_date = update_release_version.sync_changelog_release_date( - tmp_path, - "v1.2.3", - previous=_previous(), - ) - - assert changed == (changelog,) - assert release_date == "2026-08-20" - assert "## [1.2.3] - 2026-08-20" in changelog.read_text(encoding="utf-8") - assert check_docs_version_sync.find_version_mismatches(tmp_path) == [] - - -def test_main_supports_help(capsys: pytest.CaptureFixture[str]) -> None: - with pytest.raises(SystemExit, match="0"): - update_release_version.main(["--help"]) - - assert "Target stable release tag" in capsys.readouterr().out diff --git a/scripts/update_cargo_tool_pins.py b/scripts/update_cargo_tool_pins.py deleted file mode 100644 index 81f4fb1..0000000 --- a/scripts/update_cargo_tool_pins.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Reconcile repository tool pins with installed package versions.""" - -import argparse -import os -import re -import subprocess -import sys -import tempfile -from pathlib import Path - -from subprocess_utils import ExecutableNotFoundError, run_cargo_command, run_safe_command - -PIN_TO_PACKAGE = { - "cargo_edit_version": "cargo-edit", - "cargo_llvm_cov_version": "cargo-llvm-cov", - "cargo_machete_version": "cargo-machete", - "cargo_nextest_version": "cargo-nextest", - "cargo_update_version": "cargo-update", - "dprint_version": "dprint", - "git_cliff_version": "git-cliff", - "just_version": "just", - "rumdl_version": "rumdl", - "taplo_version": "taplo-cli", - "typos_version": "typos-cli", - "zizmor_version": "zizmor", -} -PIN_TO_TOOL = {**PIN_TO_PACKAGE, "uv_version": "uv"} -PACKAGE_HEADER = re.compile(r"^(?P[A-Za-z0-9_-]+) v(?P[^\s:]+):$", re.MULTILINE) -_SEMVER_IDENTIFIER = r"(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)" -VERSION = re.compile( - rf"^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)" - rf"(?:-{_SEMVER_IDENTIFIER}(?:\.{_SEMVER_IDENTIFIER})*)?" - r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" -) -STABLE_VERSION = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") -TOOL_VERSION = re.compile(r"(?[0-9]+\.[0-9]+\.[0-9]+)(?![0-9A-Za-z_.+-])") - - -def parse_installed_packages(output: str) -> dict[str, str]: - """Parse package versions from ``cargo install --list`` output.""" - packages: dict[str, str] = {} - for match in PACKAGE_HEADER.finditer(output): - package = match.group("package") - version = match.group("version") - if package in packages: - msg = f"duplicate installed Cargo package: {package}" - raise ValueError(msg) - if VERSION.fullmatch(version) is None: - msg = f"invalid installed version for {package}: {version}" - raise ValueError(msg) - packages[package] = version - return packages - - -def parse_tool_version(output: str, tool: str) -> str: - """Extract one stable semantic version from a tool's version output.""" - versions = [match.group("version") for match in TOOL_VERSION.finditer(output)] - if len(versions) != 1: - msg = f"expected exactly one {tool} version, found {len(versions)}" - raise ValueError(msg) - version = versions[0] - if STABLE_VERSION.fullmatch(version) is None: - msg = f"invalid installed version for {tool}: {version}; expected stable X.Y.Z" - raise ValueError(msg) - return str(version) - - -def update_pin_text(text: str, installed: dict[str, str]) -> tuple[str, dict[str, tuple[str, str]]]: - """Return Just source with every managed pin reconciled exactly once.""" - updated = text - changes: dict[str, tuple[str, str]] = {} - for pin, tool in PIN_TO_TOOL.items(): - version = installed.get(tool) - if version is None: - msg = f"managed tool is not installed: {tool}" - raise ValueError(msg) - assignment = re.compile(rf'^(?P{re.escape(pin)}\s*:=\s*")(?P[^"]+)(?P"\s*)$', re.MULTILINE) - matches = list(assignment.finditer(updated)) - if len(matches) != 1: - msg = f"expected exactly one {pin} assignment, found {len(matches)}" - raise ValueError(msg) - old_version = matches[0].group("version") - if old_version == version: - continue - updated = assignment.sub(rf"\g{version}\g", updated, count=1) - changes[pin] = (old_version, version) - return updated, changes - - -def reconcile_pins(justfile: Path, installed_output: str, uv_output: str) -> dict[str, tuple[str, str]]: - """Atomically reconcile ``justfile`` and return changed pin versions.""" - original = justfile.read_text(encoding="utf-8") - installed = parse_installed_packages(installed_output) - installed["uv"] = parse_tool_version(uv_output, "uv") - updated, changes = update_pin_text(original, installed) - if not changes: - return changes - - descriptor, temporary_name = tempfile.mkstemp(prefix=f".{justfile.name}.", dir=justfile.parent, text=True) - temporary = Path(temporary_name) - try: - with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as stream: - stream.write(updated) - temporary.chmod(justfile.stat().st_mode) - temporary.replace(justfile) - except BaseException: - temporary.unlink(missing_ok=True) - raise - return changes - - -def parse_args(argv: list[str] | None = None) -> argparse.Namespace: - """Parse command-line arguments.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--check-uv-version", - metavar="OUTPUT", - help="validate captured uv output as exactly one stable X.Y.Z version without reconciling pins", - ) - parser.add_argument("--justfile", type=Path, default=Path("justfile"), help="Just source containing repository tool pins") - return parser.parse_args(argv) - - -def check_uv_version(output: str) -> str: - """Parse captured uv output or explain why the reconciler cannot store it.""" - try: - return parse_tool_version(output, "uv") - except ValueError as error: - observed = output.strip() or "" - msg = f"'uv --version' must report exactly one stable X.Y.Z version; got: {observed} ({error})" - raise ValueError(msg) from error - - -def main(argv: list[str] | None = None) -> int: - """Validate uv output or reconcile pins from active tool installations.""" - args = parse_args(argv) - operation = "validate uv version" if args.check_uv_version is not None else "update tool pins" - try: - if args.check_uv_version is not None: - check_uv_version(args.check_uv_version) - return 0 - cargo = run_cargo_command(["install", "--list"], timeout=30) - uv = run_safe_command("uv", ["--version"], timeout=30) - changes = reconcile_pins(args.justfile, cargo.stdout, uv.stdout) - except (ExecutableNotFoundError, OSError, subprocess.SubprocessError, ValueError) as error: - print(f"failed to {operation}: {error}", file=sys.stderr) - return 1 - - if not changes: - print("Tool pins already match installed repository tools.") - return 0 - for pin, (old_version, new_version) in changes.items(): - print(f"Updated {pin}: {old_version} -> {new_version}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/update_python_dev_pins.py b/scripts/update_python_dev_pins.py deleted file mode 100644 index 83fa4ae..0000000 --- a/scripts/update_python_dev_pins.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Advance exact Python development-tool pins with uv's resolver.""" - -import argparse -import re -import subprocess -import sys -import tomllib -from dataclasses import dataclass -from pathlib import Path -from typing import cast - -from subprocess_utils import ExecutableNotFoundError, run_safe_command - -EXACT_REQUIREMENT = re.compile( - r"^(?P[A-Za-z0-9][A-Za-z0-9._-]*)==(?P[^;\s]+)$", -) -RESOLVED_REQUIREMENT = re.compile( - r"^(?P[A-Za-z0-9][A-Za-z0-9._-]*)==(?P[^;\s]+)(?:\s*;\s*.+)?$", -) -PYTHON_FLOOR = re.compile(r"^>=(?P[0-9]+\.[0-9]+)$") - - -@dataclass(frozen=True) -class DevPin: - """One exact development-tool requirement.""" - - name: str - version: str - - -def canonicalize_name(name: str) -> str: - """Return the normalized distribution name used for comparisons.""" - return re.sub(r"[-_.]+", "-", name).casefold() - - -def _required_table(data: dict[str, object], name: str) -> dict[str, object]: - """Return a required TOML table with a typed failure.""" - table = data.get(name) - if not isinstance(table, dict): - msg = f"pyproject.toml must contain a [{name}] table" - raise TypeError(msg) - return cast("dict[str, object]", table) - - -def _python_floor(project: dict[str, object]) -> str: - """Return the repository's single supported Python lower bound.""" - requires_python = project.get("requires-python") - if not isinstance(requires_python, str): - msg = "project.requires-python must be a string" - raise TypeError(msg) - python_match = PYTHON_FLOOR.fullmatch(requires_python) - if python_match is None: - msg = f"expected project.requires-python to be a single lower bound, found: {requires_python}" - raise ValueError(msg) - return cast("str", python_match.group("version")) - - -def _dev_pins(groups: dict[str, object]) -> list[DevPin]: - """Return direct exact dev pins, leaving included groups under their owners.""" - dev = groups.get("dev") - if not isinstance(dev, list): - msg = "dependency-groups.dev must be an array" - raise TypeError(msg) - if not dev: - msg = "dependency-groups.dev must be a non-empty array" - raise ValueError(msg) - - pins: list[DevPin] = [] - normalized_names: set[str] = set() - for requirement in dev: - if isinstance(requirement, dict) and set(requirement) == {"include-group"}: - included = requirement["include-group"] - if not isinstance(included, str) or included == "dev" or not isinstance(groups.get(included), list): - msg = "include-group must name an existing dependency group other than dev" - raise ValueError(msg) - continue - if not isinstance(requirement, str): - msg = "dependency-groups.dev entries must be strings or include-group tables" - raise TypeError(msg) - requirement_match = EXACT_REQUIREMENT.fullmatch(requirement) - if requirement_match is None: - msg = f"development-tool requirements must be exact simple pins: {requirement}" - raise ValueError(msg) - pin = DevPin(requirement_match.group("name"), requirement_match.group("version")) - normalized = canonicalize_name(pin.name) - if normalized in normalized_names: - msg = f"duplicate development-tool requirement: {pin.name}" - raise ValueError(msg) - normalized_names.add(normalized) - pins.append(pin) - return pins - - -def parse_project(text: str) -> tuple[str, list[DevPin]]: - """Parse the Python floor and exact development-tool pins.""" - data = tomllib.loads(text) - return _python_floor(_required_table(data, "project")), _dev_pins(_required_table(data, "dependency-groups")) - - -def parse_resolution(output: str, pins: list[DevPin]) -> list[DevPin]: - """Extract one resolver-selected version for every direct development tool.""" - requested = {canonicalize_name(pin.name): pin.name for pin in pins} - resolved: dict[str, set[str]] = {name: set() for name in requested} - - for raw_line in output.splitlines(): - match = RESOLVED_REQUIREMENT.fullmatch(raw_line.strip()) - if match is None: - continue - normalized = canonicalize_name(match.group("name")) - if normalized in resolved: - resolved[normalized].add(match.group("version")) - - latest: list[DevPin] = [] - for pin in pins: - versions = resolved[canonicalize_name(pin.name)] - if not versions: - msg = f"uv resolver output omitted direct development tool: {pin.name}" - raise ValueError(msg) - if len(versions) != 1: - rendered = ", ".join(sorted(versions)) - msg = f"uv resolver selected multiple versions for {pin.name}: {rendered}" - raise ValueError(msg) - latest.append(DevPin(pin.name, next(iter(versions)))) - return latest - - -def resolve_latest_pins(pins: list[DevPin], python_version: str, project_root: Path) -> list[DevPin]: - """Resolve the latest cross-platform set without changing repository files.""" - requirements = "".join(f"{pin.name}\n" for pin in pins) - result = run_safe_command( - "uv", - [ - "pip", - "compile", - "-", - "--universal", - "--no-header", - "--no-annotate", - "--python-version", - python_version, - ], - cwd=project_root, - input=requirements, - ) - return parse_resolution(result.stdout, pins) - - -def update_dev_pins(pyproject: Path) -> dict[str, tuple[str, str]]: - """Resolve and apply all changed direct pins in one uv transaction.""" - python_version, current = parse_project(pyproject.read_text(encoding="utf-8")) - latest = resolve_latest_pins(current, python_version, pyproject.parent) - changes = {old.name: (old.version, new.version) for old, new in zip(current, latest, strict=True) if old.version != new.version} - if not changes: - return changes - - run_safe_command( - "uv", - ["add", "--dev", "--no-sync", *(f"{pin.name}=={pin.version}" for pin in latest)], - cwd=pyproject.parent, - ) - return changes - - -def parse_args(argv: list[str] | None = None) -> argparse.Namespace: - """Parse command-line arguments.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--pyproject", - type=Path, - default=Path("pyproject.toml"), - help="project manifest containing direct exact dependency-groups.dev pins", - ) - return parser.parse_args(argv) - - -def _subprocess_detail(error: subprocess.CalledProcessError) -> str: - """Return captured tool diagnostics when available.""" - detail = error.stderr if isinstance(error.stderr, str) else error.stdout if isinstance(error.stdout, str) else "" - detail = detail.strip() - return cast("str", detail or str(error)) - - -def main(argv: list[str] | None = None) -> int: - """Advance exact development-tool pins and their uv lock resolution.""" - args = parse_args(argv) - try: - changes = update_dev_pins(args.pyproject) - except subprocess.CalledProcessError as error: - print(f"failed to update Python development-tool pins: {_subprocess_detail(error)}", file=sys.stderr) - return 1 - except (ExecutableNotFoundError, OSError, subprocess.TimeoutExpired, TypeError, ValueError) as error: - print(f"failed to update Python development-tool pins: {error}", file=sys.stderr) - return 1 - - if not changes: - print("Python development-tool pins are already current.") - return 0 - for name, (old_version, new_version) in changes.items(): - print(f"Updated {name}: {old_version} -> {new_version}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/update_release_version.py b/scripts/update_release_version.py deleted file mode 100644 index ddb7f02..0000000 --- a/scripts/update_release_version.py +++ /dev/null @@ -1,521 +0,0 @@ -"""Update deterministic release-version references from one target Git tag.""" - -import argparse -import os -import re -import subprocess -import sys -import tempfile -import tomllib -from dataclasses import dataclass, field -from datetime import UTC, date, datetime -from pathlib import Path -from typing import TYPE_CHECKING - -import check_docs_version_sync as version_sync -from archive_performance import published_stable_release_tags -from subprocess_utils import ExecutableNotFoundError - -if TYPE_CHECKING: - from collections.abc import Callable - -_STABLE_TAG_RE = re.compile(r"^v(?P0|[1-9][0-9]*)\.(?P0|[1-9][0-9]*)\.(?P0|[1-9][0-9]*)$") -_TOML_VERSION_RE = re.compile(r'^(?P\s*version\s*=\s*")(?P[^"]+)(?P"\s*(?:#.*)?)$') -_CITATION_VERSION_RE = re.compile( - r"^(?Pversion:\s*(?P['\"]?))" - r"(?P[0-9A-Za-z][0-9A-Za-z.+-]*)" - r"(?P(?P=quote)\s*(?:#.*)?)$" -) -_CITATION_DATE_RE = re.compile( - r"^(?Pdate-released:\s*(?P['\"]?))" - r"(?P\d{4}-\d{2}-\d{2})" - r"(?P(?P=quote)\s*(?:#.*)?)$" -) -_BENCHMARK_TAG_PAIR_RE = re.compile( - r"(?Pjust performance-(?:github-assets|local-non-exact|release)[ \t]+)" - r"v(?P[0-9]+\.[0-9]+\.[0-9]+)" - r"(?P[ \t]+)" - r"v(?P[0-9]+\.[0-9]+\.[0-9]+)" - r"(?=[ \t]|`|$)", - re.MULTILINE, -) -_BENCHMARK_BASELINE_PROSE_RE = re.compile( - r"(?PThis generates a local `v)" - r"(?P[0-9]+\.[0-9]+\.[0-9]+)" - r"(?P` `vs_linalg` baseline)" -) - - -@dataclass(frozen=True, order=True, slots=True) -class ReleaseTag: - """A stable release tag with SemVer ordering.""" - - major: int - minor: int - patch: int - tag: str = field(compare=False) - - @property - def version(self) -> str: - """Return the package version without the leading ``v``.""" - return self.tag.removeprefix("v") - - -@dataclass(frozen=True, slots=True) -class UpdateSummary: - """Files and release identities produced by an update.""" - - target: ReleaseTag - previous: ReleaseTag - changed_paths: tuple[Path, ...] - release_date: str - - -@dataclass(frozen=True, slots=True) -class LineReplacement: - """One fail-closed scalar replacement on a known source line.""" - - line_number: int - pattern: re.Pattern[str] - group: str - replacement: str - allowed: frozenset[str] - context: str - - -def parse_release_tag(value: str, *, label: str = "release tag") -> ReleaseTag: - """Parse one stable ``vX.Y.Z`` release tag.""" - match = _STABLE_TAG_RE.fullmatch(value) - if match is None: - msg = f"{label} must be a stable tag in vX.Y.Z form, got {value!r}" - raise ValueError(msg) - return ReleaseTag( - major=int(match.group("major")), - minor=int(match.group("minor")), - patch=int(match.group("patch")), - tag=value, - ) - - -def select_previous_release_tag(tag_names: list[str], target: ReleaseTag) -> ReleaseTag: - """Select the newest published stable release before *target*.""" - stable_tags = [parse_release_tag(tag) for tag in tag_names if _STABLE_TAG_RE.fullmatch(tag) is not None] - if not stable_tags: - msg = "repository has no published stable vX.Y.Z GitHub releases" - raise ValueError(msg) - newer = [tag for tag in stable_tags if tag > target] - if newer: - latest = max(newer) - msg = f"target {target.tag} is older than published stable GitHub release {latest.tag}" - raise ValueError(msg) - previous = [tag for tag in stable_tags if tag < target] - if not previous: - msg = f"could not find a published stable GitHub release before {target.tag}" - raise ValueError(msg) - return max(previous) - - -def infer_previous_release_tag(root: Path, target: ReleaseTag) -> ReleaseTag: - """Infer the previous release from published stable GitHub releases.""" - return select_previous_release_tag(published_stable_release_tags(root), target) - - -def _current_utc_date() -> str: - """Return today's UTC calendar date for release preparation.""" - return datetime.now(UTC).date().isoformat() - - -def _validated_date(value: str) -> str: - """Require one real ISO calendar date and return it unchanged.""" - try: - parsed = date.fromisoformat(value) - except ValueError as error: - msg = f"release date must use YYYY-MM-DD, got {value!r}" - raise ValueError(msg) from error - if parsed.isoformat() != value: - msg = f"release date must use canonical YYYY-MM-DD form, got {value!r}" - raise ValueError(msg) - return value - - -def _replace_line_group(text: str, edit: LineReplacement) -> str: - lines = text.splitlines(keepends=True) - if not 1 <= edit.line_number <= len(lines): - msg = f"{edit.context} has no line {edit.line_number}" - raise ValueError(msg) - original_line = lines[edit.line_number - 1] - body = original_line.rstrip("\r\n") - ending = original_line[len(body) :] - match = edit.pattern.fullmatch(body) - if match is None: - msg = f"{edit.context}:{edit.line_number} has an unsupported version assignment: {body!r}" - raise ValueError(msg) - current = match.group(edit.group) - if current not in edit.allowed: - msg = f"{edit.context}:{edit.line_number} has unexpected version {current!r}; expected one of {sorted(edit.allowed)}" - raise ValueError(msg) - start, end = match.span(edit.group) - lines[edit.line_number - 1] = f"{body[:start]}{edit.replacement}{body[end:]}{ending}" - return "".join(lines) - - -def _replace_match_groups(match: re.Match[str], replacements: dict[str, str]) -> str: - updated = match.group(0) - spans = sorted(((match.start(group) - match.start(), match.end(group) - match.start(), value) for group, value in replacements.items()), reverse=True) - for start, end, value in spans: - updated = f"{updated[:start]}{value}{updated[end:]}" - return updated - - -def _replace_dependency_versions(text: str, package_name: str, target: ReleaseTag, previous: ReleaseTag, path: Path) -> str: - allowed = frozenset({target.version, previous.version}) - pattern = version_sync.dependency_regex(package_name) - - def replace(match: re.Match[str]) -> str: - group = "plain" if match.group("plain") is not None else "table" - current = match.group(group) - if current not in allowed: - msg = f"{path} has unexpected {package_name} dependency version {current!r}; expected one of {sorted(allowed)}" - raise ValueError(msg) - return _replace_match_groups(match, {group: target.version}) - - return pattern.sub(replace, text) - - -def _replace_readme_links(text: str, target: ReleaseTag, previous: ReleaseTag, path: Path) -> str: - allowed = frozenset({target.version, previous.version}) - - def replace(match: re.Match[str]) -> str: - if version_sync.readme_tag_link_is_benchmark_asset(match): - return match.group(0) - version = match.group("version") - if version is not None and version not in allowed: - msg = f"{path} has unexpected release-pinned link version {version!r}; expected one of {sorted(allowed)}" - raise ValueError(msg) - group = "version" if version is not None else "revision" - replacement = target.version if version is not None else target.tag - return _replace_match_groups(match, {group: replacement}) - - return version_sync.README_TAG_LINK_RE.sub(replace, text) - - -def _replace_benchmark_tag_pairs(text: str, target: ReleaseTag, previous: ReleaseTag, path: Path) -> str: - allowed_current = frozenset({target.version, previous.version}) - - def replace(match: re.Match[str]) -> str: - current = match.group("current") - if current not in allowed_current: - msg = f"{path} has unexpected benchmark current tag v{current}; expected {target.tag} or {previous.tag}" - raise ValueError(msg) - return _replace_match_groups(match, {"current": target.version, "baseline": previous.version}) - - return _BENCHMARK_TAG_PAIR_RE.sub(replace, text) - - -def _replace_benchmark_baseline_prose(text: str, previous: ReleaseTag) -> str: - """Keep the active specific-release explanation aligned with its command.""" - return _BENCHMARK_BASELINE_PROSE_RE.sub( - lambda match: _replace_match_groups(match, {"baseline": previous.version}), - text, - ) - - -def _read_text(path: Path) -> str: - return path.read_text(encoding="utf-8") - - -def _metadata_updates(root: Path, target: ReleaseTag, previous: ReleaseTag, release_date: str) -> dict[Path, str]: - allowed = frozenset({target.version, previous.version}) - cargo_toml = root / "Cargo.toml" - cargo_lock = root / "Cargo.lock" - pyproject = root / "pyproject.toml" - uv_lock = root / "uv.lock" - citation = root / "CITATION.cff" - - package = version_sync.read_cargo_package_info(cargo_toml) - project = version_sync.read_python_project_info(pyproject) - cargo_toml_line = version_sync.toml_table_key_line(cargo_toml, "package", "version") - cargo_lock_reference = version_sync.cargo_lock_reference(cargo_lock, package) - pyproject_reference = version_sync.pyproject_reference(pyproject, project) - uv_lock_reference = version_sync.uv_lock_reference(uv_lock, project) - citation_reference = version_sync.citation_reference(citation) - - updates = { - cargo_toml: _replace_line_group( - _read_text(cargo_toml), - LineReplacement( - line_number=cargo_toml_line, - pattern=_TOML_VERSION_RE, - group="version", - replacement=target.version, - allowed=allowed, - context=str(cargo_toml), - ), - ), - cargo_lock: _replace_line_group( - _read_text(cargo_lock), - LineReplacement( - line_number=cargo_lock_reference.line, - pattern=_TOML_VERSION_RE, - group="version", - replacement=target.version, - allowed=allowed, - context=str(cargo_lock), - ), - ), - pyproject: _replace_line_group( - _read_text(pyproject), - LineReplacement( - line_number=pyproject_reference.line, - pattern=_TOML_VERSION_RE, - group="version", - replacement=target.version, - allowed=allowed, - context=str(pyproject), - ), - ), - uv_lock: _replace_line_group( - _read_text(uv_lock), - LineReplacement( - line_number=uv_lock_reference.line, - pattern=_TOML_VERSION_RE, - group="version", - replacement=target.version, - allowed=allowed, - context=str(uv_lock), - ), - ), - citation: _replace_line_group( - _read_text(citation), - LineReplacement( - line_number=citation_reference.line, - pattern=_CITATION_VERSION_RE, - group="version", - replacement=target.version, - allowed=allowed, - context=str(citation), - ), - ), - } - citation_line, current_date = version_sync.citation_release_date(citation) - updates[citation] = _replace_line_group( - updates[citation], - LineReplacement( - line_number=citation_line, - pattern=_CITATION_DATE_RE, - group="date", - replacement=release_date, - allowed=frozenset({current_date, release_date}), - context=str(citation), - ), - ) - return updates - - -def _prepare_updates(root: Path, target: ReleaseTag, previous: ReleaseTag, release_date: str) -> dict[Path, str]: - updates = _metadata_updates(root, target, previous, release_date) - changelog = root / "CHANGELOG.md" - changelog_match = version_sync.changelog_release_date(changelog, target.version) - if changelog_match is not None: - changelog_line, changelog_date = changelog_match - updates[changelog] = _replace_changelog_release_date( - changelog, - target, - line=changelog_line, - current_date=changelog_date, - release_date=release_date, - ) - package = version_sync.read_cargo_package_info(root / "Cargo.toml") - for path in version_sync.iter_active_markdown_files(root): - original = _read_text(path) - updated = _replace_dependency_versions(original, package.name, target, previous, path) - updated = _replace_benchmark_tag_pairs(updated, target, previous, path) - updated = _replace_benchmark_baseline_prose(updated, previous) - if path == root / "README.md": - updated = _replace_readme_links(updated, target, previous, path) - updates[path] = updated - return updates - - -def _replace_changelog_release_date( - changelog: Path, - target: ReleaseTag, - *, - line: int, - current_date: str, - release_date: str, -) -> str: - """Return a changelog with one target release heading date synchronized.""" - heading_re = re.compile( - rf"^(?P## \[v?{re.escape(target.version)}\] - )" - r"(?P\d{4}-\d{2}-\d{2})$" - ) - return _replace_line_group( - _read_text(changelog), - LineReplacement( - line_number=line, - pattern=heading_re, - group="date", - replacement=release_date, - allowed=frozenset({current_date, release_date}), - context=str(changelog), - ), - ) - - -def _write_text_atomic(path: Path, text: str) -> None: - descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent, text=True) - temporary = Path(temporary_name) - try: - with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as stream: - stream.write(text) - temporary.chmod(path.stat().st_mode) - temporary.replace(path) - except BaseException: - temporary.unlink(missing_ok=True) - raise - - -def _validate_updated_root(root: Path, target: ReleaseTag, previous: ReleaseTag) -> None: - mismatches = version_sync.find_version_mismatches(root) - if mismatches: - details = "; ".join( - f"{mismatch.reference.path.relative_to(root)}:{mismatch.reference.line} has {mismatch.reference.version}" for mismatch in mismatches - ) - msg = f"release-version validation failed after updating to {target.tag}: {details}" - raise ValueError(msg) - for path in version_sync.iter_active_markdown_files(root): - for match in _BENCHMARK_TAG_PAIR_RE.finditer(_read_text(path)): - if match.group("current") != target.version or match.group("baseline") != previous.version: - msg = f"{path} contains a benchmark tag pair that does not match {target.tag} against {previous.tag}" - raise ValueError(msg) - for match in _BENCHMARK_BASELINE_PROSE_RE.finditer(_read_text(path)): - if match.group("baseline") != previous.version: - msg = f"{path} contains active baseline prose that does not match {previous.tag}" - raise ValueError(msg) - - -def _publish_transaction(updates: dict[Path, str], validate: Callable[[], None]) -> tuple[Path, ...]: - originals = {path: _read_text(path) for path in updates} - changed = tuple(sorted((path for path, text in updates.items() if text != originals[path]), key=str)) - replaced: list[Path] = [] - try: - for path in changed: - _write_text_atomic(path, updates[path]) - replaced.append(path) - validate() - except BaseException as primary: - rollback_errors: list[str] = [] - for path in reversed(replaced): - try: - _write_text_atomic(path, originals[path]) - except OSError as error: - rollback_errors.append(f"{path}: {error}") - if rollback_errors: - msg = f"release-version update failed ({primary}); rollback also failed: {'; '.join(rollback_errors)}" - raise RuntimeError(msg) from primary - raise - return changed - - -def update_release_version( - root: Path, - tag: str, - *, - previous: ReleaseTag | None = None, - release_date: str | None = None, -) -> UpdateSummary: - """Update release references transactionally and return a summary.""" - resolved_root = root.resolve() - target = parse_release_tag(tag, label="target tag") - previous_release = previous or infer_previous_release_tag(resolved_root, target) - if previous_release >= target: - msg = f"previous release {previous_release.tag} must be older than target {target.tag}" - raise ValueError(msg) - prepared_date = _validated_date(release_date or _current_utc_date()) - updates = _prepare_updates(resolved_root, target, previous_release, prepared_date) - changed = _publish_transaction(updates, lambda: _validate_updated_root(resolved_root, target, previous_release)) - return UpdateSummary(target=target, previous=previous_release, changed_paths=changed, release_date=prepared_date) - - -def sync_changelog_release_date( - root: Path, - tag: str, - *, - previous: ReleaseTag | None = None, -) -> tuple[tuple[Path, ...], str]: - """Synchronize a generated changelog heading from ``CITATION.cff``.""" - resolved_root = root.resolve() - target = parse_release_tag(tag, label="target tag") - previous_release = previous or infer_previous_release_tag(resolved_root, target) - package = version_sync.read_cargo_package_info(resolved_root / "Cargo.toml") - if package.version != target.version: - msg = f"Cargo.toml version {package.version} does not match target {target.tag}" - raise ValueError(msg) - citation = resolved_root / "CITATION.cff" - _, citation_date = version_sync.citation_release_date(citation) - changelog = resolved_root / "CHANGELOG.md" - changelog_match = version_sync.changelog_release_date(changelog, target.version) - if changelog_match is None: - msg = f"{changelog} has no generated release heading for {target.tag}" - raise ValueError(msg) - changelog_line, changelog_date = changelog_match - updated = _replace_changelog_release_date( - changelog, - target, - line=changelog_line, - current_date=changelog_date, - release_date=citation_date, - ) - changed = _publish_transaction( - {changelog: updated}, - lambda: _validate_updated_root( - resolved_root, - target, - previous_release, - ), - ) - return changed, citation_date - - -def parse_args(argv: list[str] | None = None) -> argparse.Namespace: - """Parse command-line arguments.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("tag", help="Target stable release tag in vX.Y.Z form") - parser.add_argument("--root", type=Path, default=Path.cwd(), help="Repository root to update (default: current directory)") - parser.add_argument( - "--sync-changelog-date", - action="store_true", - help="Synchronize the generated changelog heading from CITATION.cff instead of updating release metadata", - ) - return parser.parse_args(argv) - - -def main(argv: list[str] | None = None) -> int: - """Update deterministic release metadata with fail-closed diagnostics.""" - args = parse_args(argv) - try: - if args.sync_changelog_date: - changed_paths, release_date = sync_changelog_release_date(args.root, args.tag) - if changed_paths: - print(f"Synchronized CHANGELOG.md release date to {release_date}.") - else: - print(f"CHANGELOG.md release date already matches {release_date}.") - return 0 - summary = update_release_version(args.root, args.tag) - except (ExecutableNotFoundError, OSError, RuntimeError, subprocess.SubprocessError, TypeError, ValueError, tomllib.TOMLDecodeError) as error: - print(f"failed to update release version: {error}", file=sys.stderr) - return 1 - - if summary.changed_paths: - for path in summary.changed_paths: - print(f"Updated {path.relative_to(args.root.resolve())}") - else: - print(f"Release-version references already match {summary.target.tag}.") - print(f"Previous release: {summary.previous.tag}") - print(f"CITATION.cff release date: {summary.release_date} (UTC update date)") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/semgrep.yaml b/semgrep.yaml index 63328a0..f781be5 100644 --- a/semgrep.yaml +++ b/semgrep.yaml @@ -435,7 +435,7 @@ rules: languages: - yaml severity: ERROR - message: "Set zizmor-action's version from steps.zizmor_version.outputs.version, resolved with just --evaluate zizmor_version." + message: "Run zizmor through the declared research-repo-tools toolchain instead of zizmor-action." metadata: category: security rationale: >- @@ -454,24 +454,17 @@ rules: - metavariable-regex: metavariable: $ACTION regex: ^zizmorcore/zizmor-action@ - - pattern-not: | - uses: $ACTION - with: - ... - version: ${{ steps.zizmor_version.outputs.version }} - ... - ... - - id: la-stack.github-actions.zizmor-version-resolved-from-just + - id: la-stack.github-actions.zizmor-managed-execution languages: - yaml severity: ERROR - message: "Resolve the zizmor_version step's output with the canonical just --evaluate zizmor_version sequence in .github/workflows/zizmor.yml." + message: "Run zizmor through research-repo-tools toolchain run so system binaries cannot bypass declared pins." metadata: category: security rationale: >- - An output expression alone does not prove scanner parity. Require the - resolver to read justfile and publish that value without replacement. + Direct scanner execution can select an undeclared system binary. + The checked runner verifies the managed executable and its version. paths: include: - "/.github/workflows/**/*.yml" @@ -480,28 +473,17 @@ rules: - "/tests/semgrep/.github/workflows/**/*.yaml" patterns: - pattern: | - id: zizmor_version - ... run: $SCRIPT - metavariable-pattern: metavariable: $SCRIPT language: bash - patterns: - - pattern: ... - - pattern-not-inside: | - set -euo pipefail - version="$(just --evaluate zizmor_version)" - if [[ -z "$version" ]]; then - echo "::error::Could not resolve zizmor_version from justfile" - exit 1 - fi - echo "version=$version" >> "$GITHUB_OUTPUT" + pattern: zizmor ... - id: la-stack.github-actions.release-no-dependency-cache languages: - yaml severity: ERROR - message: "Release artifact jobs must avoid cache actions and explicitly disable setup-rust-toolchain, setup-uv, and setup-just caches." + message: "Release artifact jobs must avoid cache actions and explicitly disable setup-rust-toolchain, setup-uv, and setup-tools caches." metadata: category: security rationale: >- @@ -514,6 +496,7 @@ rules: - "/.github/workflows/release-*.yaml" - "/tests/semgrep/.github/workflows/release-*.yml" - "/tests/semgrep/.github/workflows/release-*.yaml" + - "/.github/actions/prepare-release-benchmarks/action.yml" pattern-either: - patterns: - pattern: | @@ -557,7 +540,7 @@ rules: ... - metavariable-regex: metavariable: $ACTION - regex: '^(?:\./|\$/)\.github/actions/setup-just/?$' + regex: '^(?:\./|\$/)\.github/actions/setup-(?:just|tools)/?$' - pattern-not: | uses: $ACTION with: diff --git a/tests/semgrep/.github/workflows/release-cache-policy.yml b/tests/semgrep/.github/workflows/release-cache-policy.yml index f54d269..3c64405 100644 --- a/tests/semgrep/.github/workflows/release-cache-policy.yml +++ b/tests/semgrep/.github/workflows/release-cache-policy.yml @@ -40,21 +40,21 @@ jobs: - name: Cache-free just # ok: la-stack.github-actions.release-no-dependency-cache - uses: ./.github/actions/setup-just + uses: ./.github/actions/setup-tools with: cache: false - name: Implicit just cache # ruleid: la-stack.github-actions.release-no-dependency-cache - uses: ./.github/actions/setup-just + uses: ./.github/actions/setup-tools # ok: la-stack.github-actions.release-no-dependency-cache - - uses: ./.github/actions/setup-just + - uses: ./.github/actions/setup-tools with: cache: false # ruleid: la-stack.github-actions.release-no-dependency-cache - - uses: ./.github/actions/setup-just + - uses: ./.github/actions/setup-tools with: cache: true diff --git a/tests/semgrep/.github/workflows/zizmor_policy.yml b/tests/semgrep/.github/workflows/zizmor_policy.yml index 90ab293..458dd11 100644 --- a/tests/semgrep/.github/workflows/zizmor_policy.yml +++ b/tests/semgrep/.github/workflows/zizmor_policy.yml @@ -1,120 +1,21 @@ -name: Zizmor scanner version fixtures -on: - workflow_dispatch: - +name: zizmor policy fixtures +on: push jobs: - fixtures: + scan: runs-on: ubuntu-latest steps: - - name: Resolve the canonical scanner version - # ok: la-stack.github-actions.zizmor-version-resolved-from-just - id: zizmor_version - shell: bash - run: | - set -euo pipefail - version="$(just --evaluate zizmor_version)" - if [[ -z "$version" ]]; then - echo "::error::Could not resolve zizmor_version from justfile" - exit 1 - fi - echo "version=$version" >> "$GITHUB_OUTPUT" - - - name: Hard-coded resolver output - # ruleid: la-stack.github-actions.zizmor-version-resolved-from-just - id: zizmor_version - shell: bash - run: | - set -euo pipefail - version="1.29.0" - if [[ -z "$version" ]]; then - echo "::error::Could not resolve zizmor_version from justfile" - exit 1 - fi - echo "version=$version" >> "$GITHUB_OUTPUT" - - - name: A source read must not justify an unrelated output - # ruleid: la-stack.github-actions.zizmor-version-resolved-from-just - id: zizmor_version - shell: bash - run: | - set -euo pipefail - version="$(just --evaluate zizmor_version)" - if [[ -z "$version" ]]; then - echo "::error::Could not resolve zizmor_version from justfile" - exit 1 - fi - echo "version=1.29.0" >> "$GITHUB_OUTPUT" - - # ok: la-stack.github-actions.zizmor-version-resolved-from-just - - id: zizmor_version - run: | - set -euo pipefail - version="$(just --evaluate zizmor_version)" - if [[ -z "$version" ]]; then - echo "::error::Could not resolve zizmor_version from justfile" - exit 1 - fi - echo "version=$version" >> "$GITHUB_OUTPUT" - - # ruleid: la-stack.github-actions.zizmor-version-resolved-from-just - - id: zizmor_version - run: | - set -euo pipefail - version="$(just --evaluate cargo_nextest_version)" - if [[ -z "$version" ]]; then - echo "::error::Could not resolve zizmor_version from justfile" - exit 1 - fi - echo "version=$version" >> "$GITHUB_OUTPUT" - - - name: A resolved version must not be overwritten - # ruleid: la-stack.github-actions.zizmor-version-resolved-from-just - id: zizmor_version - run: | - set -euo pipefail - version="$(just --evaluate zizmor_version)" - if [[ -z "$version" ]]; then - echo "::error::Could not resolve zizmor_version from justfile" - exit 1 - fi - version="1.29.0" - echo "version=$version" >> "$GITHUB_OUTPUT" - - - name: Resolved scanner version - # ok: la-stack.github-actions.zizmor-tool-version-pinned - uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 - with: - inputs: .github - version: ${{ steps.zizmor_version.outputs.version }} - - - name: Floating scanner version - # ruleid: la-stack.github-actions.zizmor-tool-version-pinned - uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 - with: - inputs: .github - version: latest - - - name: Missing scanner version - # ruleid: la-stack.github-actions.zizmor-tool-version-pinned - uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 - with: - inputs: .github - - - name: Independent pin can drift from justfile - # ruleid: la-stack.github-actions.zizmor-tool-version-pinned - uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 - with: - version: "1.30.0" - - # ok: la-stack.github-actions.zizmor-tool-version-pinned - - uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 - with: - version: ${{ steps.zizmor_version.outputs.version }} - # ruleid: la-stack.github-actions.zizmor-tool-version-pinned - - uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 - - # A later step's version must not satisfy the preceding step. - uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 with: - version: ${{ steps.zizmor_version.outputs.version }} + version: "1.30.1" + # ruleid: la-stack.github-actions.zizmor-managed-execution + - run: zizmor --persona regular --format sarif .github + # ruleid: la-stack.github-actions.zizmor-managed-execution + - run: | + set -euo pipefail + zizmor --persona regular .github + # ok: la-stack.github-actions.zizmor-managed-execution + - run: | + set -euo pipefail + uv run --locked --no-sync research-repo-tools \ + toolchain run -- zizmor --persona regular --format sarif .github diff --git a/uv.lock b/uv.lock index 4adf350..73d4de3 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,9 @@ version = 1 revision = 3 requires-python = ">=3.14" +[manifest] +overrides = [{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.15.1,<3" }] + [[package]] name = "actionlint-py" version = "1.7.12.25" @@ -824,11 +827,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, ] [package.optional-dependencies]