diff --git a/CHANGELOG.md b/CHANGELOG.md index b130384..1549423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Exclude static-analysis fixtures from CodeRabbit review and leave docstring policy to Ruff. - [**breaking**] Harden rational APIs and release comparisons [`d8f9897`](https://github.com/acgetchell/la-stack/commit/d8f9897f59cce0a220efdbbcbaaf9180861889e9) + - canonicalize signed and unreduced rational inputs at construction boundaries - preserve invariant-bearing RationalVector solutions across both exact input domains - retain typed singularity, conversion, and runtime-dispatch diagnostics @@ -167,6 +168,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Document runtime estimates, headroom, and pre-release verification. - Enforce benchmark setup limits and improve README navigation [`d8382ea`](https://github.com/acgetchell/la-stack/commit/d8382eac0b6b467ea390ea04a5ec04fb4600bc8a) + - Limit checkout to 2 minutes and share a 28-minute timeout across tool installation, input validation, and benchmark inventory. - Align budget documentation with the enforced setup limits. - Place scalar types, API navigation, and features after Quickstart, move Examples before Benchmarks, and update the Contents list. @@ -183,6 +185,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update release instructions to use --ref "$TAG" instead of main. - Correct interval bounds and support mutable dispatch captures [`3fb215b`](https://github.com/acgetchell/la-stack/commit/3fb215b6dcbc17d5ed209b4b35a6038d8e0362af) + - Use magnitude-ordered FastTwoSum to avoid spurious non-finite errors in interval addition and subtraction near f64::MAX. - Allow dimension-dispatch macro bodies to mutate captures while preserving support for consuming closures. - Skip redundant GCD work when clearing rational denominators. @@ -208,6 +211,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Publish allocation and timing data with provenance and reproduction steps supporting the existing borrowed implementation. - [**breaking**] Optimize exact conversion and dense 4D determinants [`7fd6d8e`](https://github.com/acgetchell/la-stack/commit/7fd6d8efd3cf6c12be5a591d87addd6e4961fa82) + - Avoid redundant fraction reduction in strict RationalVector conversion. - Share minors in dense exact 4×4 determinants while preserving the sparse fast path. - Add adversarial solve benchmarks across D=2,3,4,5,8,16,32,64 and exact-arithmetic diagnostics. @@ -265,6 +269,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Document exact matrix/RHS scaling and the power-of-two factor that preserves linear systems. - [**breaking**] Unify local and release performance workflows [`c601c41`](https://github.com/acgetchell/la-stack/commit/c601c4146b01868a29288b7221e413706e3e01c7) + - Retain local Markdown, CSV, and provenance artifacts while allowing same-version comparisons of tracked changes. - Exclude untracked files explicitly and isolate the narrowed non-exact comparison bundle. - Promote distinct-release artifacts through performance-doc and keep performance-release atomic and rollback-capable. @@ -306,6 +311,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [setuptools](https://github.com/pypa/setuptools) to permit the latest version. Updates `ruff` from 0.16.1 to 0.16.2 + - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.1...0.16.2) @@ -643,6 +649,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Revalidate finite proof conversions [`419a90f`](https://github.com/acgetchell/la-stack/commit/419a90f7267608051736498154ac5e6faf0909c5) Ensure internal finite proof conversions cannot accept raw Matrix or Vector storage without checking the invariant. + - Revalidate TryFrom<Matrix<D>> and TryFrom<Vector<D>> before constructing finite wrappers. - Measure exact random percentile benchmarks over repeated corpus timings and cumulative input sets. - Tighten Codecov status thresholds and extend benchmark workflow timeout. @@ -796,6 +803,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 APIs to catch tail cases that fixed well-conditioned inputs miss. Benchmarks (benches/exact.rs): + - Factor out `bench_extreme_group` helper running the same four benches (`det_sign_exact`, `det_exact`, `solve_exact`, `solve_exact_f64`) so adversarial groups are directly comparable. @@ -1066,6 +1074,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Replace `BigRational::from_float(x)` in `f64_to_bigrational` with manual IEEE 754 binary64 bit decomposition and `BigRational::new_raw`, bypassing the unnecessary GCD normalization that `from_float` performs internally. + - Decompose f64 into sign, biased exponent, and significand fields - Strip trailing zeros from the significand so the fraction is already in lowest terms (odd numerator over power-of-two denominator) @@ -1081,6 +1090,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 all f64 entries are decomposed into mantissa × 2^exponent, scaled to a common integer base, and eliminated without any rational arithmetic. The result is reconstructed as BigRational only at the end. + - Add f64_decompose helper (extracted from f64_to_bigrational) - Add bareiss_det_int: integer-only Bareiss returning (BigInt, i32) - Add bigint_exp_to_bigrational: reconstruction with trailing-zero @@ -1105,9 +1115,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Older releases are archived by minor series: -- [0.3.x](docs/archive/changelog/0.3.md) -- [0.2.x](docs/archive/changelog/0.2.md) -- [0.1.x](docs/archive/changelog/0.1.md) +- [0.3.x](docs/archives/changelog/0.3.md) +- [0.2.x](docs/archives/changelog/0.2.md) +- [0.1.x](docs/archives/changelog/0.1.md) [0.4.6]: https://github.com/acgetchell/la-stack/compare/v0.4.5...v0.4.6 [0.4.5]: https://github.com/acgetchell/la-stack/compare/v0.4.4...v0.4.5 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7cce9c..271a36f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ clarity, and the fixed-dimension stack-allocation model. Install Rust 1.98.1 through [rustup](https://rustup.rs/), Git, the [GitHub CLI](https://cli.github.com/), Python 3.14, -[`uv` 0.12.5](https://docs.astral.sh/uv/), and `jq`. Authenticate the GitHub +[`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: @@ -27,6 +27,19 @@ 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 commands 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 +`tooling` requirement and refresh `uv.lock` together, then run the consumer +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 first adoption phase 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 diff --git a/changelog-rumdl.toml b/changelog-rumdl.toml new file mode 100644 index 0000000..9f8b77a --- /dev/null +++ b/changelog-rumdl.toml @@ -0,0 +1,7 @@ +# Inherit repository Markdown rules for candidates before atomic publication. +extends = "pyproject.toml" + +[global] +# Archive links can point to candidates that have not been published yet. +# The normal repository Markdown check still validates their final destinations. +extend-disable = [ "MD057" ] diff --git a/cliff.toml b/cliff.toml index 9c64064..9f8f6ce 100644 --- a/cliff.toml +++ b/cliff.toml @@ -1,5 +1,8 @@ # git-cliff configuration for la-stack # https://git-cliff.org/docs/configuration +# Consumer-owned exception to the packaged policy: preserve historical +# chore(deps-dev) bodies and their upstream release-note/changelog links. +# See scripts/README.md for the migration boundary. [changelog] # Keep a Changelog header diff --git a/docs/RELEASING.md b/docs/RELEASING.md index d181c6a..43f8004 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -93,13 +93,21 @@ continuing. ### 3. Generate the release changelog ```bash -just changelog-unreleased "$TAG" +RELEASE_DATE="$(date -u +%F)" +just changelog-unreleased "$TAG" "$RELEASE_DATE" ``` This generates `CHANGELOG.md` as though the target tag already existed, archives -completed minor series under `docs/archive/changelog/`, and synchronizes the -changelog heading with the UTC preparation date recorded in `CITATION.cff`. -Review the generated changelog and any archive changes. +completed minor series under `docs/archives/changelog/`, and uses the explicit +ISO release date. Verify that `RELEASE_DATE` equals the date recorded in +`CITATION.cff` by metadata preparation; if preparation crossed UTC midnight, +rerun `just update-version "$TAG"` first. Generation preserves existing release +dates and does not update package or citation metadata. +Use `just changelog-preview --tag "$TAG" --date "$RELEASE_DATE"` for a read-only +preview, and `just changelog-check` to validate the root and every archive. +Review the generated changelog and any archive changes. The +[Scripts guide](../scripts/README.md#changelog-and-release-tooling) explains +the consumer policy exception that preserves historical dependency-note links. ### 4. Generate the release performance comparison @@ -161,7 +169,7 @@ git --no-pager diff Expected release artifacts include package metadata and lockfiles, `CITATION.cff`, `CHANGELOG.md`, `README.md`, `docs/performance.md`, and generated -files under `docs/archive/` and `docs/assets/bench/`. Stage only the reviewed +files under `docs/archive/`, `docs/archives/changelog/`, and `docs/assets/bench/`. Stage only the reviewed paths that were intentionally changed; do not stage the entire `docs/` tree. Then inspect the staged diff and commit it: @@ -189,7 +197,7 @@ focused release preparation without feature work. ### Handling fixes found during preparation For a critical fix that must be included, make and commit the fix, rerun -`just changelog-unreleased "$TAG"`, review and stage only the resulting changelog +`just changelog-unreleased "$TAG" "$RELEASE_DATE"`, review and stage only the resulting changelog files, and commit that generated update separately. For a non-critical fix, file an issue and defer it to a later release. Do not diff --git a/docs/archive/changelog/0.1.md b/docs/archives/changelog/0.1.md similarity index 100% rename from docs/archive/changelog/0.1.md rename to docs/archives/changelog/0.1.md diff --git a/docs/archive/changelog/0.2.md b/docs/archives/changelog/0.2.md similarity index 99% rename from docs/archive/changelog/0.2.md rename to docs/archives/changelog/0.2.md index fb89834..202f02c 100644 --- a/docs/archive/changelog/0.2.md +++ b/docs/archives/changelog/0.2.md @@ -200,6 +200,7 @@ used in delaunay. New files: + - cliff.toml: Keep a Changelog template with commit parsers for mixed commit history, PR/commit link rewriting via preprocessors - scripts/tag_release.py: CLI for annotated tag creation with semver diff --git a/docs/archive/changelog/0.3.md b/docs/archives/changelog/0.3.md similarity index 99% rename from docs/archive/changelog/0.3.md rename to docs/archives/changelog/0.3.md index baf6d16..5badd2c 100644 --- a/docs/archive/changelog/0.3.md +++ b/docs/archives/changelog/0.3.md @@ -22,6 +22,7 @@ - Update docs: README, AGENTS.md, REFERENCES.md, module doc, typos.toml - [**breaking**] Expose exact determinant value (det_exact, det_exact_f64) [`92ce476`](https://github.com/acgetchell/la-stack/commit/92ce476201d5759766d73221612cd27492bccbe5) + - Add `det_exact()` returning `BigRational` via Bareiss elimination - Add `det_exact_f64()` converting exact result to f64 - Add `LaError::Overflow` for f64 conversion overflow @@ -31,6 +32,7 @@ - Macro-generate exact tests for D=2..5 - Update README, AGENTS.md, REFERENCES.md, typos.toml - Exact linear system solve (solve_exact, solve_exact_f64) [`d04fcd3`](https://github.com/acgetchell/la-stack/commit/d04fcd3b24843b7377a34849dbb2e6469bf9fc56) + - Add `solve_exact()` returning `[BigRational; D]` via Gaussian elimination with partial pivoting in BigRational - Add `solve_exact_f64()` converting exact result to `Vector<D>` @@ -83,6 +85,7 @@ - Use idiomatic (0..D).rev() for back-substitution loop - Add LDLT example, solve_exact README snippet, and sync examples [`2bddccf`](https://github.com/acgetchell/la-stack/commit/2bddccf3f20156fadb4fee6a4093e76b1b631de1) + - Add examples/ldlt_solve_3x3.rs: 3×3 SPD tridiagonal LDLT solve + det - Add solve_exact_f64 code example to README exact arithmetic section - Add missing examples to justfile (ldlt_solve_3x3, exact_det_3x3, diff --git a/docs/code_organization.md b/docs/code_organization.md index e440f29..f987555 100644 --- a/docs/code_organization.md +++ b/docs/code_organization.md @@ -108,7 +108,11 @@ 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, -release metadata, changelog generation/archiving, and tag preparation. +and release metadata. 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`; +focused integration checks live in `scripts/tests/test_changelog_integration.py`. The [justfile](../justfile) owns executable development workflows. `scripts/release_baseline.py` owns release-suite inventory and complete raw diff --git a/docs/dev/docs.md b/docs/dev/docs.md index ff22738..6a6b76c 100644 --- a/docs/dev/docs.md +++ b/docs/dev/docs.md @@ -123,7 +123,8 @@ dependency snippets synchronized with `Cargo.toml` and follow version: crates.io documentation changes also require a new version. Never edit `CHANGELOG.md` directly. `just changelog` generates, post-processes, -archives, and formats the changelog; `just changelog-unreleased ` +archives, and formats the changelog through the pinned shared CLI; +`just changelog-unreleased ` prepends unreleased changes. Commit-message guidance lives in [Git and GitHub guidance](MANAGING_CHANGES.md). Benchmark report and generated asset ownership belongs in [Benchmarking](../BENCHMARKING.md). diff --git a/justfile b/justfile index 2c70c07..e2815ce 100644 --- a/justfile +++ b/justfile @@ -361,44 +361,34 @@ build-release: cargo-lock-check: cargo metadata --locked --format-version 1 --no-deps > /dev/null -# Changelog generation (git-cliff + post-processing + archiving) +# 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 + +# 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 #!/usr/bin/env bash set -euo pipefail - GIT_CLIFF_OFFLINE=true git-cliff -o CHANGELOG.md - uv run --locked postprocess-changelog - uv run --locked archive-changelog - archive_files=() - if [ -d docs/archive/changelog ]; then - while IFS= read -r -d '' file; do - archive_files+=("$file") - done < <(find docs/archive/changelog -name '*.md' -print0) - fi - if ((${#archive_files[@]})); then - rumdl fmt --silent CHANGELOG.md "${archive_files[@]}" - else - rumdl fmt --silent CHANGELOG.md - fi + 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 -# Prepend unreleased changes to CHANGELOG.md for the given version -changelog-unreleased version: _ensure-git-cliff _ensure-rumdl python-sync +# Validate generation and print the root changelog without publishing candidates. +[positional-arguments] +changelog-preview *args: _ensure-git-cliff _ensure-rumdl python-sync #!/usr/bin/env bash set -euo pipefail - GIT_CLIFF_OFFLINE=true git-cliff --tag {{ quote(version) }} -o CHANGELOG.md - uv run --locked postprocess-changelog - uv run --locked archive-changelog - uv run --locked update-release-version {{ quote(version) }} --sync-changelog-date - archive_files=() - if [ -d docs/archive/changelog ]; then - while IFS= read -r -d '' file; do - archive_files+=("$file") - done < <(find docs/archive/changelog -name '*.md' -print0) - fi - if ((${#archive_files[@]})); then - rumdl fmt --silent CHANGELOG.md "${archive_files[@]}" - else - rumdl fmt --silent CHANGELOG.md - fi + uv run --locked --group dev 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) }} + +alias changelog-unreleased := changelog-release # Check (non-mutating): run all linters/validators check: lint @@ -411,7 +401,7 @@ check-fast: # CI simulation: flat GitHub-equivalent union of leaf validators. # Keep this dependency list explicit so each validation surface runs once without # re-entering broad check/test bundles. All Cargo targets match the SARIF lint scope. -ci: action-lint zizmor markdown-check spell-check docs-version-check toml-parse-check toml-fmt-check toml-lint yaml-fmt-check yaml-lint citation-check validate-json justfile-fmt-check python-format-check python-lint python-fixture-lint python-typecheck test-python cargo-lock-check fmt-check clippy-all-targets doc-check semgrep semgrep-test unused-deps shell-check test-rust-ci test-doc test-doc-exact bench-compile examples +ci: action-lint zizmor markdown-check spell-check docs-version-check changelog-check toml-parse-check toml-fmt-check toml-lint yaml-fmt-check yaml-lint citation-check validate-json justfile-fmt-check python-format-check python-lint python-fixture-lint python-typecheck test-python cargo-lock-check fmt-check clippy-all-targets doc-check semgrep semgrep-test unused-deps shell-check test-rust-ci test-doc test-doc-exact bench-compile examples @echo "🎯 CI checks complete!" # Validate CITATION.cff against the Citation File Format schema. @@ -537,7 +527,12 @@ help-workflows: @echo "" @echo "Changelog & releases:" @echo " just changelog # Regenerate CHANGELOG.md from full history" - @echo " just changelog-unreleased # Prepend unreleased changes for a version" + @echo " just changelog-preview # Preview generation without writing" + @echo " just changelog-release # Generate a release with an explicit date" + @echo " just changelog-unreleased # Alias for changelog-release" + @echo " just changelog-archive # Rotate existing changelog history" + @echo " just changelog-check # Validate root and archived release notes" + @echo " just release-notes # Print root or archived release notes" @echo " just tag # Create annotated tag from CHANGELOG.md" @echo " just tag-force # Recreate an existing tag" @echo " just update-version # Update release metadata and infer the previous tag" @@ -577,7 +572,7 @@ lint-code: rust-core-check python-check shell-check lint-config: json-check toml-ci yaml-ci github-actions-check justfile-fmt-check -lint-docs: markdown-ci docs-version-check +lint-docs: markdown-ci docs-version-check changelog-check # Markdown markdown-check: _ensure-rumdl _ensure-uv @@ -586,7 +581,7 @@ markdown-check: _ensure-rumdl _ensure-uv files=() while IFS= read -r -d '' file; do case "$file" in - CHANGELOG.md|docs/archive/*) continue ;; + CHANGELOG.md|docs/archive/*|docs/archives/changelog/*) continue ;; esac if [ -f "$file" ]; then files+=("$file") @@ -608,7 +603,7 @@ markdown-fix: _ensure-rumdl files=() while IFS= read -r -d '' file; do case "$file" in - CHANGELOG.md|docs/archive/*) continue ;; + CHANGELOG.md|docs/archive/*|docs/archives/changelog/*) continue ;; esac if [ -f "$file" ]; then files+=("$file") @@ -727,6 +722,10 @@ python-sync: _ensure-uv python-typecheck: python-sync uv run --locked ty check scripts/ tests/semgrep/scripts/ --error all +# Print release notes from the root changelog or a completed minor archive. +release-notes tag: python-sync + uv run --locked --group dev research-repo-tools changelog notes {{ quote(tag) }} + rust-core-check: cargo-lock-check fmt-check clippy-core doc-check semgrep semgrep-test unused-deps @echo "✅ Rust core checks complete!" @@ -972,11 +971,11 @@ spell-check: _ensure-typos # Create an annotated git tag from the CHANGELOG.md section for the given version tag version: python-sync - uv run --locked tag-release {{ quote(version) }} + uv run --locked --group dev research-repo-tools changelog tag {{ quote(version) }} -# Recreate an existing tag (delete + recreate) +# Replace an existing local tag only after validating the release notes. tag-force version: python-sync - uv run --locked tag-release {{ quote(version) }} --force + uv run --locked --group dev research-repo-tools changelog tag {{ quote(version) }} --force # Testing: runnable Rust tests use nextest; rustdoc doctests remain on cargo test. test: test-lib test-doc diff --git a/pyproject.toml b/pyproject.toml index 69294c7..ce2e116 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,12 +34,9 @@ dependencies = [ ] "Bug Tracker" = "https://github.com/acgetchell/la-stack/issues" [project.scripts] -archive-changelog = "archive_changelog:main" archive-performance = "archive_performance:main" bench-compare = "bench_compare:main" criterion-dim-plot = "criterion_dim_plot:main" -postprocess-changelog = "postprocess_changelog:main" -tag-release = "tag_release: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" @@ -48,7 +45,7 @@ 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_changelog", "archive_performance", "bench_compare", "benchmark_contract", "benchmark_summaries", "check_docs_version_sync", "check_semgrep_fixtures", "criterion_dim_plot", "criterion_measurements", "performance_artifacts", "postprocess_changelog", "subprocess_utils", "tag_release", "update_cargo_tool_pins", "update_python_dev_pins", "update_release_version" ] +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" ] [tool.ruff] line-length = 160 @@ -92,7 +89,6 @@ max-complexity = 10 [tool.ruff.lint.isort] known-first-party = [ - "archive_changelog", "archive_performance", "bench_compare", "benchmark_contract", @@ -101,9 +97,7 @@ known-first-party = [ "criterion_dim_plot", "criterion_measurements", "performance_artifacts", - "postprocess_changelog", "subprocess_utils", - "tag_release", "update_cargo_tool_pins", "update_python_dev_pins", ] @@ -153,7 +147,9 @@ line-length = 160 package = true [dependency-groups] +tooling = [ "research-repo-tools==0.1.7" ] dev = [ + { include-group = "tooling" }, "actionlint-py==1.7.12.24", "pytest==9.1.1", "ruff==0.16.6", @@ -163,3 +159,17 @@ dev = [ "ty==0.0.79", "yamllint==1.38.0", ] + +[tool.research-repo-tools] +schema = 1 + +[tool.research-repo-tools.changelog] +owner = "acgetchell" +repository = "la-stack" +formatter = "changelog-rumdl.toml" +# Historical deps-dev bodies contain release-note links that the packaged policy omits. +cliff-config = "cliff.toml" + +[tool.research-repo-tools.release] +# Release preparation and post-merge tagging can happen on different UTC days. +date-policy = "declared" diff --git a/scripts/README.md b/scripts/README.md index 31c316f..8816a7d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -272,18 +272,63 @@ the citation date. # Full regeneration from all history just changelog -# Prepend only unreleased changes for a new version -just changelog-unreleased vX.Y.Z +# Preview without replacing the root or archive files +just changelog-preview + +# Generate a prospective release with an explicit ISO date +just changelog-unreleased vX.Y.Z YYYY-MM-DD +# Equivalent name: +just changelog-release vX.Y.Z YYYY-MM-DD + +# Rotate existing notes without regenerating Git history +just changelog-archive +just changelog-check +just release-notes vX.Y.Z ``` -`just changelog` runs `git-cliff -o CHANGELOG.md`, strips trailing blank -lines, archives completed changelog series, and formats the generated Markdown. -Configuration lives in `cliff.toml` at the repo root. The Breaking Changes -summary preserves full `BREAKING CHANGE` footer descriptions, including compiler -requirements and migration instructions; commits marked only with `!` fall back -to their subject. Post-processing escapes HTML in breaking-description prose -while preserving literal Rust syntax in Markdown code spans and fenced blocks, -and adds merged-PR summaries without replacing those descriptions. +The exact published `research-repo-tools==0.1.7` dependency is in `tooling`, +included by `dev`, and resolved from PyPI in `uv.lock`. Thin Just recipes use +the documented `research-repo-tools changelog` CLI. Its implementation and +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 +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. + +`just changelog` generates, normalizes, formats, and rotates completed minor +series in one operation. It retains Unreleased and the newest minor series in +`CHANGELOG.md`, with older releases in `docs/archives/changelog/MAJOR.MINOR.md`. +Existing dated headings remain authoritative. Prospective generation requires +a `v`-prefixed SemVer tag and an explicit ISO date, and leaves package and +citation metadata untouched. Prepare metadata separately with +`just update-version`; choose the same date recorded in `CITATION.cff`. +Preview validates root and archive candidates without publishing them. +Malformed versions/dates, duplicate releases, conflicting retained notes, +and formatter failures stop publication with diagnostics. + +`cliff.toml` remains a deliberate consumer policy exception: the packaged +policy newly groups historical `chore(deps-dev)` entries as Dependencies and +omits their authored bodies. Those bodies contain retained Ruff, Ty, and +setuptools release-note, changelog, and comparison links. Retaining the existing +policy preserves this history and its original code-span representation. +The shared normalizer preserves complete breaking-change descriptions, Markdown +links, and literal code in the input while adding merged-PR summaries. +It retains embedded conventional headings that the old normalizer deduplicated. + +`changelog-rumdl.toml` inherits the repository Markdown policy and disables +MD057 only while formatting unpublished candidates, whose future archive paths +do not yet exist. Repository Markdown checks and focused archive-link checks +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. No +notebook or review-tool migration is included here. ### Creating a release tag @@ -292,15 +337,20 @@ just tag vX.Y.Z # create an annotated tag matching Cargo.toml just tag-force vX.Y.Z # replace that tag only when explicitly repairing it ``` -The `tag-release` CLI (in `tag_release.py`) extracts the matching version -section from `CHANGELOG.md`, requires the tag to match the Cargo package version, -validates SemVer, and handles GitHub's 125KB tag-annotation size limit. +These recipes call `research-repo-tools changelog tag`. It searches the root +and canonical archives, validates the entire history, and requires the +`v`-prefixed tag to match the Cargo package version. The consumer's `declared` +date policy requires the dated release heading to match any `CITATION.cff` date, +so a release prepared before merge can be tagged on a later UTC day. Oversized annotations +link to the full notes to respect GitHub's 125KB limit. `--force` replaces an +existing local ref only after validation; it does not first delete the old tag. +Tagging never pushes or publishes a release. Use the CLI's `--dry-run` to +preview an annotation without creating a tag. ### Scripts overview | Script | Purpose | |---|---| -| `archive_changelog.py` | Split completed changelog minor series into archives | | `archive_performance.py` | Promote release performance docs and archive older comparisons | | `performance_artifacts.py` | Validate and publish schema-versioned performance-comparison CSV/JSON inputs | | `bench_compare.py` | Compare Criterion benchmark baselines and render Markdown reports | @@ -308,8 +358,6 @@ validates SemVer, and handles GitHub's 125KB tag-annotation size limit. | `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 | -| `tag_release.py` | Create annotated git tags from CHANGELOG.md sections | -| `postprocess_changelog.py` | Normalize and reflow generated git-cliff Markdown safely | | `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 | diff --git a/scripts/archive_changelog.py b/scripts/archive_changelog.py deleted file mode 100755 index 1da184b..0000000 --- a/scripts/archive_changelog.py +++ /dev/null @@ -1,596 +0,0 @@ -#!/usr/bin/env -S uv run -"""Archive completed minor series from CHANGELOG.md into per-minor files. - -Parses the full CHANGELOG.md (produced by git-cliff + postprocess-changelog) -into version blocks, groups them by minor series (X.Y), and writes: - - - ``docs/archive/changelog/X.Y.md`` for each completed minor series - - A trimmed ``CHANGELOG.md`` containing only the preamble, Unreleased, - the active minor series, and an Archives link section - -The active minor is detected from the first tagged release heading after -Unreleased. All other minors are archived. - -Usage: - archive-changelog # default: CHANGELOG.md - archive-changelog path/to/CHANGELOG.md - archive-changelog --archive-dir docs/archive/changelog -""" - -import argparse -import logging -import os -import re -import sys -import tempfile -from itertools import pairwise -from pathlib import Path - -from postprocess_changelog import normalize_entry_headings_text, postprocess_text - -# Matches ``## [X.Y.Z]`` or ``## [Unreleased]`` -_VERSION_HEADING_RE = re.compile(r"^## \[") -_UNRELEASED_HEADING_RE = re.compile(r"^## \[Unreleased\](?:\s|$)") - -# Extracts a strict SemVer 2.0.0 version from a ``## [X.Y.Z]`` heading. -_SEMVER_ALNUM_ID = r"(?:(?=[0-9A-Za-z-]*[A-Za-z-])[0-9A-Za-z-]+)" -_SEMVER_PATTERN = ( - r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" - rf"(?:-(?:(?:0|[1-9]\d*)|{_SEMVER_ALNUM_ID})(?:\.(?:(?:0|[1-9]\d*)|{_SEMVER_ALNUM_ID}))*)?" - r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" -) -_VERSION_RE = re.compile(rf"^## \[({_SEMVER_PATTERN})\](?:\s|$)") - -# Matches a reference-style link definition: ``[label]: URL`` -_LINK_DEF_RE = re.compile(r"^\[([^\]]+)\]:\s+\S+") - -# Archive directory relative to the repository root. -_DEFAULT_ARCHIVE_DIR = "docs/archive/changelog" - -LOGGER = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Parsing helpers -# --------------------------------------------------------------------------- - - -def _minor_key(version: str) -> str: - """Return the ``X.Y`` minor key for a semver version string. - - Parameters: - version: A version string like ``0.7.2`` or ``1.2.3-rc.1``. - - Returns: - The first two numeric components joined by a dot (e.g. ``0.7``). - - Raises: - ValueError: If *version* does not contain at least two dot-separated components. - """ - parts = version.split(".") - if len(parts) < 2: - msg = f"Expected a version with at least two components (X.Y), got: {version!r}" - raise ValueError(msg) - return f"{parts[0]}.{parts[1]}" - - -def _version_sort_key(label: str) -> tuple[bool, tuple[int, ...], tuple[tuple[int, int | str], ...]]: - """Return a sort key for a version label that orders by semantic version. - - Non-numeric labels (e.g. ``unreleased``) sort after all numeric versions. - Numeric parts are compared as integers so that ``0.10`` sorts after ``0.9``. - - Parameters: - label: A version label like ``0.7.2``, ``0.10``, or ``unreleased``. - - Returns: - A tuple suitable for use as a sort key. - """ - label_without_build = label.split("+", 1)[0] - core, separator, prerelease = label_without_build.partition("-") - parts = core.split(".") - try: - nums = tuple(int(p) for p in parts) - except ValueError: - # Non-numeric labels ("unreleased") sort last (True > False). - return (True, (), ()) - - if not separator: - prerelease_key: tuple[tuple[int, int | str], ...] = ((2, ""),) - else: - prerelease_key = tuple((0, int(part)) if part.isdecimal() else (1, part) for part in prerelease.split(".")) - - return (False, nums, prerelease_key) - - -def _extract_link_defs(text: str) -> tuple[str, dict[str, str]]: - """Separate trailing reference-style link definitions from changelog text. - - git-cliff appends reference-style link definitions at the bottom of - CHANGELOG.md for every version heading. When the changelog is split - into per-version blocks these definitions must be distributed to the - correct output files so that headings like ``## [0.7.2]`` resolve and - no unused definitions trigger rumdl MD053. - - Parameters: - text: The full changelog text. - - Returns: - A 2-tuple of (*cleaned_text*, *link_defs*) where *link_defs* maps - lowercase labels to their full definition lines. - """ - lines = text.rstrip("\n").split("\n") - link_defs: dict[str, str] = {} - - # Walk backwards from the end, collecting link-def and blank lines. - i = len(lines) - 1 - while i >= 0: - line = lines[i] - m = _LINK_DEF_RE.match(line) - if m: - link_defs[m.group(1).lower()] = line - i -= 1 - elif line.strip() == "": - i -= 1 - else: - break - - cleaned = "\n".join(lines[: i + 1]) - return cleaned.rstrip("\n") + "\n", link_defs - - -def parse_changelog(text: str) -> tuple[str, str, list[tuple[str, str]]]: - """Split a full changelog into preamble, unreleased block, and version blocks. - - Parameters: - text: The full contents of CHANGELOG.md. - - Returns: - A 3-tuple of (preamble, unreleased_block, version_blocks). The - ``unreleased_block`` is the complete ``## [Unreleased]`` block, - including its heading. Each item in ``version_blocks`` is a - ``(semver_label, full_heading_block)`` pair in the order it appears - (newest first), where ``semver_label`` is only the parsed version text - (for example ``"0.7.2"``) and ``full_heading_block`` still includes - the raw ``## [...]`` heading and body. - """ - lines = text.split("\n") - - # Locate all ``## [`` headings. - headings: list[int] = [] - for i, line in enumerate(lines): - if _VERSION_HEADING_RE.match(line): - headings.append(i) - - if not headings: - return text, "", [] - - preamble = "\n".join(lines[: headings[0]]) - - unreleased = "" - unreleased_line: int | None = None - version_blocks: list[tuple[str, str]] = [] - version_lines: dict[str, int] = {} - - for idx, start in enumerate(headings): - end = headings[idx + 1] if idx + 1 < len(headings) else len(lines) - block = "\n".join(lines[start:end]) - - heading_line = lines[start] - if _UNRELEASED_HEADING_RE.match(heading_line): - if unreleased_line is not None: - msg = f"Duplicate Unreleased changelog heading at lines {unreleased_line} and {start + 1}" - raise ValueError(msg) - unreleased = block - unreleased_line = start + 1 - else: - m = _VERSION_RE.match(heading_line) - if not m: - msg = f"Unrecognized changelog version heading at line {start + 1}: {heading_line!r}; expected '## [Unreleased]' or a semantic version" - raise ValueError(msg) - version = m.group(1) - if version in version_lines: - msg = f"Duplicate changelog version {version!r} at lines {version_lines[version]} and {start + 1}" - raise ValueError(msg) - version_lines[version] = start + 1 - version_blocks.append((version, block)) - - return preamble, unreleased, version_blocks - - -def group_by_minor( - version_blocks: list[tuple[str, str]], -) -> dict[str, list[tuple[str, str]]]: - """Group version blocks by their ``X.Y`` minor key. - - Preserves insertion order (newest first within each minor). - - Parameters: - version_blocks: List of ``(version, block_text)`` pairs. - - Returns: - An ordered dict mapping minor keys to their version blocks. - """ - groups: dict[str, list[tuple[str, str]]] = {} - for ver, block in version_blocks: - key = _minor_key(ver) - groups.setdefault(key, []).append((ver, block)) - return groups - - -def _validate_release_order(version_blocks: list[tuple[str, str]]) -> None: - """Require release headings to be in strictly descending SemVer order.""" - for (previous, _), (current, _) in pairwise(version_blocks): - same_precedence = _version_sort_key(previous) == _version_sort_key(current) - out_of_order = sorted((previous, current), key=_version_sort_key, reverse=True) != [previous, current] - if same_precedence or out_of_order: - msg = f"changelog release headings must be in strictly descending semantic-version order: {previous} appears before {current}" - raise ValueError(msg) - - -# --------------------------------------------------------------------------- -# Writers -# --------------------------------------------------------------------------- - - -def _format_link_defs(link_defs: dict[str, str], labels: set[str]) -> str: - """Return the subset of *link_defs* whose labels are in *labels*. - - The definitions are returned in reverse-sorted order (matching the - convention that git-cliff uses: ``[unreleased]`` first, then newest - version to oldest). - """ - relevant = [link_defs[label] for label in sorted(link_defs, key=_version_sort_key, reverse=True) if label in labels] - return "\n".join(relevant) if relevant else "" - - -def write_archive( - archive_dir: Path, - minor: str, - blocks: list[tuple[str, str]], - link_defs: dict[str, str] | None = None, -) -> Path: - """Write an archive file for a single minor series. - - Parameters: - archive_dir: Directory for archive files. - minor: The ``X.Y`` minor key. - blocks: Version blocks belonging to this minor, newest first, using - the ``(semver_label, full_heading_block)`` shape returned by - ``parse_changelog``. The archive writer preserves each provided - block verbatim after the generated archive title. - link_defs: Optional mapping of lowercase labels to reference-style - link definition lines. Only definitions matching versions in - *blocks* are included. - - Returns: - The path of the written archive file. - """ - path = archive_dir / f"{minor}.md" - text = _build_archive_text(minor, blocks, link_defs) - _publish_texts({path: text}) - return path - - -def _build_archive_text( - minor: str, - blocks: list[tuple[str, str]], - link_defs: dict[str, str] | None = None, -) -> str: - """Build one normalized archive payload without changing the filesystem.""" - parts = [f"# Changelog - {minor}.x\n"] - for _ver, block in blocks: - parts.append(block) - - text = "\n".join(parts) - - # Append only the reference-style link definitions for this archive. - if link_defs: - versions = {ver.lower() for ver, _ in blocks} - defs_text = _format_link_defs(link_defs, versions) - if defs_text: - text = text.rstrip("\n") + "\n\n" + defs_text - - # Normalize archive output too; archived blocks can preserve historical - # commit-body indentation that no longer appears in the trimmed root file. - return postprocess_text(text) - - -def _stage_text(path: Path, text: str) -> Path: - path.parent.mkdir(parents=True, exist_ok=True) - staged: Path | None = None - try: - with tempfile.NamedTemporaryFile( - "w", - encoding="utf-8", - newline="", - dir=path.parent, - prefix=f".{path.name}.", - suffix=".tmp", - delete=False, - ) as handle: - staged = Path(handle.name) - handle.write(text) - handle.flush() - os.fsync(handle.fileno()) - except BaseException: - if staged is not None: - staged.unlink(missing_ok=True) - raise - if staged is None: - msg = "temporary changelog staging completed without a path" - raise AssertionError(msg) - return staged - - -def _stage_bytes(path: Path, payload: bytes) -> Path: - path.parent.mkdir(parents=True, exist_ok=True) - staged: Path | None = None - try: - with tempfile.NamedTemporaryFile( - "wb", - dir=path.parent, - prefix=f".{path.name}.", - suffix=".tmp", - delete=False, - ) as handle: - staged = Path(handle.name) - handle.write(payload) - handle.flush() - os.fsync(handle.fileno()) - except BaseException: - if staged is not None: - staged.unlink(missing_ok=True) - raise - if staged is None: - msg = "temporary changelog restoration completed without a path" - raise AssertionError(msg) - return staged - - -def _replace_path(source: Path, destination: Path) -> None: - source.replace(destination) - - -def _restore_text(path: Path, previous: bytes | None) -> None: - if previous is None: - path.unlink(missing_ok=True) - return - staged = _stage_bytes(path, previous) - try: - _replace_path(staged, path) - finally: - staged.unlink(missing_ok=True) - - -def _publish_texts(payloads: dict[Path, str]) -> None: - """Publish a set of text files together and roll all of them back on failure.""" - if not payloads: - return - previous = {path: path.read_bytes() if path.is_file() else None for path in payloads} - staged: dict[Path, Path] = {} - replaced: list[Path] = [] - try: - for path, text in payloads.items(): - staged[path] = _stage_text(path, text) - for path, staged_path in staged.items(): - _replace_path(staged_path, path) - replaced.append(path) - except BaseException as publication_error: - rollback_errors: list[BaseException] = [] - for path in reversed(replaced): - try: - _restore_text(path, previous[path]) - except BaseException as rollback_error: # noqa: BLE001 - rollback_errors.append(rollback_error) - if rollback_errors: - group_message = "changelog publication and rollback failed" - raise BaseExceptionGroup( - group_message, - [publication_error, *rollback_errors], - ) from None - raise - finally: - for staged_path in staged.values(): - staged_path.unlink(missing_ok=True) - - -def _postprocess_existing_archives(archive_dir: Path) -> None: - """Normalize historical archive files that are not regenerated this run.""" - if not archive_dir.is_dir(): - return - - payloads: dict[Path, str] = {} - for path in archive_dir.glob("*.md"): - text = path.read_text(encoding="utf-8") - normalized = normalize_entry_headings_text(text) - if normalized != text: - payloads[path] = normalized - _publish_texts(payloads) - - -def _normalized_existing_archive_payloads(archive_dir: Path, *, excluded: set[Path]) -> dict[Path, str]: - """Return changed historical archive payloads not regenerated this run.""" - if not archive_dir.is_dir(): - return {} - payloads: dict[Path, str] = {} - for path in archive_dir.glob("*.md"): - if path in excluded: - continue - text = path.read_text(encoding="utf-8") - normalized = normalize_entry_headings_text(text) - if normalized != text: - payloads[path] = normalized - return payloads - - -def build_root( - preamble: str, - unreleased: str, - active_blocks: list[tuple[str, str]], - archived_minors: list[str], - archive_dir_rel: str, -) -> str: - """Assemble the trimmed root CHANGELOG.md content. - - Parameters: - preamble: Text before the first ``## `` heading. - unreleased: The full Unreleased block (empty string if absent). - active_blocks: Version blocks for the active minor series. - archived_minors: Sorted list of archived ``X.Y`` minor keys. - archive_dir_rel: Relative path to the archive directory from the changelog file. - - Returns: - The full text for the trimmed CHANGELOG.md. - """ - parts: list[str] = [preamble] - - if unreleased: - parts.append(unreleased) - - for _ver, block in active_blocks: - parts.append(block) - - if archived_minors: - # Build the Archives section. - archive_lines = ["## Archives\n"] - archive_lines.append("Older releases are archived by minor series:\n") - archive_lines.extend(f"- [{minor}.x]({archive_dir_rel}/{minor}.md)" for minor in archived_minors) - archive_lines.append("") - parts.append("\n".join(archive_lines)) - - return postprocess_text("\n".join(parts)) - - -def _archive_dir_link_prefix(archive_dir: Path, changelog_parent: Path) -> str: - """Return the Markdown link prefix from a changelog to its archive directory.""" - try: - return archive_dir.relative_to(changelog_parent).as_posix() - except ValueError: - try: - archive_dir_rel = Path(os.path.relpath(archive_dir, changelog_parent)).as_posix() - except ValueError as err: - msg = "cannot compute relative archive links because the archive and changelog directories are on different filesystem roots" - raise ValueError(msg) from err - if archive_dir_rel == ".." or archive_dir_rel.startswith("../") or Path(archive_dir_rel).is_absolute(): - LOGGER.warning( - "Archive directory %s is outside changelog directory %s; generated Markdown links use %s", - archive_dir, - changelog_parent, - archive_dir_rel, - ) - return archive_dir_rel - - -# --------------------------------------------------------------------------- -# Orchestrator -# --------------------------------------------------------------------------- - - -def archive_changelog( - changelog_path: Path, - archive_dir: Path | None = None, -) -> None: - """Split a changelog into root + per-minor archive files. - - Parameters: - changelog_path: Path to the full CHANGELOG.md. - archive_dir: Directory for archive files. Defaults to - ``docs/archive/changelog`` relative to *changelog_path*'s parent. - """ - if archive_dir is None: - archive_dir = changelog_path.parent / _DEFAULT_ARCHIVE_DIR - - text = changelog_path.read_text(encoding="utf-8") - - # Separate trailing reference-style link definitions before parsing - # so they can be distributed to the correct output files. - text, link_defs = _extract_link_defs(text) - - preamble, unreleased, version_blocks = parse_changelog(text) - _validate_release_order(version_blocks) - - if not version_blocks: - _postprocess_existing_archives(archive_dir) - return # nothing to archive - - groups = group_by_minor(version_blocks) - minor_keys = list(groups.keys()) - - # Active minor = first minor that appears (newest release). - active_minor = minor_keys[0] - - # Archive every minor except the active one. - archived_minors = minor_keys[1:] - - if not archived_minors: - _postprocess_existing_archives(archive_dir) - return # only one minor series — nothing to archive yet - - # Validate link portability before creating or modifying archive files. - # In particular, os.path.relpath() cannot cross Windows volumes. - archive_dir_rel = _archive_dir_link_prefix(archive_dir, changelog_path.parent) - - payloads = {archive_dir / f"{minor}.md": _build_archive_text(minor, groups[minor], link_defs) for minor in archived_minors} - - root_text = build_root( - preamble, - unreleased, - groups[active_minor], - sorted(archived_minors, key=_version_sort_key, reverse=True), - archive_dir_rel, - ) - - # Append reference-style link definitions for active versions. - if link_defs: - labels: set[str] = {ver.lower() for ver, _ in groups[active_minor]} - if unreleased: - labels.add("unreleased") - defs_text = _format_link_defs(link_defs, labels) - if defs_text: - root_text = root_text.rstrip("\n") + "\n\n" + defs_text + "\n" - - payloads[changelog_path] = root_text - payloads.update(_normalized_existing_archive_payloads(archive_dir, excluded=set(payloads))) - _publish_texts(payloads) - - -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- - - -def main(argv: list[str] | None = None) -> int: - """CLI entry point for ``archive-changelog``.""" - parser = argparse.ArgumentParser( - prog="archive-changelog", - description="Archive completed minor series from CHANGELOG.md.", - ) - parser.add_argument( - "path", - nargs="?", - default="CHANGELOG.md", - help="Path to CHANGELOG.md (default: CHANGELOG.md)", - ) - parser.add_argument( - "--archive-dir", - default=None, - help=f"Archive output directory (default: {_DEFAULT_ARCHIVE_DIR})", - ) - args = parser.parse_args(argv) - - changelog = Path(args.path) - try: - if not changelog.is_file(): - msg = f"{changelog} not found" - raise FileNotFoundError(msg) - archive_dir = Path(args.archive_dir) if args.archive_dir else None - archive_changelog(changelog, archive_dir) - except (OSError, UnicodeError, ValueError) as error: - print(f"archive-changelog: {error}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/check_docs_version_sync.py b/scripts/check_docs_version_sync.py index 8aca452..412c176 100644 --- a/scripts/check_docs_version_sync.py +++ b/scripts/check_docs_version_sync.py @@ -20,6 +20,7 @@ ".tmp_pycache", ".venv", "archive", + "archives", "target", "tests", } diff --git a/scripts/postprocess_changelog.py b/scripts/postprocess_changelog.py deleted file mode 100644 index c7e6952..0000000 --- a/scripts/postprocess_changelog.py +++ /dev/null @@ -1,1001 +0,0 @@ -#!/usr/bin/env -S uv run -"""Post-process a git-cliff generated CHANGELOG.md. - -Applies lightweight markdown hygiene that is difficult to express in -Tera templates: - - 1. Inject summary sections (Breaking Changes, Merged Pull Requests). - 2. Reflow long lines at word boundaries, preserving markdown links - and code spans as atomic tokens (MD013). - 3. Tag bare fenced code blocks with a language (MD040). - 4. Normalize indented commit-body headings (MD023). - 5. Normalize list continuation indentation (MD077). - 6. Normalize escaped email autolinks (MD034). - 7. Strip trailing blank lines (MD012). - -Usage: - postprocess-changelog # default: CHANGELOG.md - postprocess-changelog path/to/CHANGELOG.md -""" - -import argparse -import os -import re -import stat -import sys -import tempfile -from dataclasses import dataclass -from pathlib import Path -from typing import cast - -# rumdl MD013 line-length limit used by this project. -MAX_LINE_WIDTH = 160 - - -@dataclass(frozen=True, slots=True) -class _CodeFence: - """Delimiter evidence for an open Markdown fenced code block.""" - - delimiter: str - length: int - - -_FENCE_RE = re.compile(r"^(?P[ \t]*)(?P`{3,}|~{3,})(?P.*)$") - - -def _backtick_span_end(text: str, start: int) -> int | None: - """Return the end of a code span whose closing run matches its opener.""" - delimiter_length = 1 - while start + delimiter_length < len(text) and text[start + delimiter_length] == "`": - delimiter_length += 1 - - position = start + delimiter_length - while position < len(text): - run_start = text.find("`", position) - if run_start < 0: - return None - run_end = run_start + 1 - while run_end < len(text) and text[run_end] == "`": - run_end += 1 - if run_end - run_start == delimiter_length: - return run_end - position = run_end - return None - - -def _balanced_delimiter_end(text: str, start: int, opening: str, closing: str) -> int | None: - """Return the end of a balanced delimiter pair, honoring escapes.""" - depth = 1 - position = start + 1 - while position < len(text): - character = text[position] - if character == "\\" and position + 1 < len(text): - position += 2 - continue - if character == opening: - depth += 1 - elif character == closing: - depth -= 1 - if depth == 0: - return position + 1 - position += 1 - return None - - -def _markdown_link_end(text: str, start: int) -> int | None: - """Return the end of an inline link, balancing brackets and parentheses.""" - label_end = _balanced_delimiter_end(text, start, "[", "]") - if label_end is None or label_end >= len(text) or text[label_end] != "(": - return None - return _balanced_delimiter_end(text, label_end, "(", ")") - - -def _markdown_tokens(text: str) -> list[str]: - """Tokenize reflowable prose without splitting links or code spans.""" - tokens: list[str] = [] - position = 0 - while position < len(text): - while position < len(text) and text[position].isspace(): - position += 1 - if position >= len(text): - break - - token_end: int | None = None - if text[position] == "[": - token_end = _markdown_link_end(text, position) - elif text[position] == "`": - token_end = _backtick_span_end(text, position) - - if token_end is None: - token_end = position + 1 - while token_end < len(text) and not text[token_end].isspace(): - token_end += 1 - tokens.append(text[position:token_end]) - position = token_end - return tokens - - -# Version section heading: ## [X.Y.Z], ## [vX.Y.Z], or ## [Unreleased] -_VERSION_RE = re.compile( - r"^## \[(?:v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?|Unreleased)\]" - r"(?:\s+-\s+\d{4}-\d{2}-\d{2})?\s*$" -) - -# PR link: [#123](https://github.com/.../pull/123) -_PR_LINK_RE = re.compile(r"\[#(\d+)\]\(https://github\.com/[^)]+/pull/\d+\)") - -# Commit-hash link to strip from summary lines. -_COMMIT_LINK_RE = re.compile(r"\s*\[`[a-f0-9]{7}`\]\(https://github\.com/[^)]+/commit/[a-f0-9]+\)") - -# Leading git-cliff breaking marker to strip from normalized comparison keys. -_BREAKING_MARKER_RE = re.compile(r"^\s*(?:[-*]\s+)?\[?\*\*breaking\*\*\]?\s*", re.IGNORECASE) - -# Leading ``* `` list marker to normalise to ``- `` (MD004). -_STAR_LIST_RE = re.compile(r"^(\s*)\* ") - -# Extra spaces after list marker: ``- `` → ``- `` (MD030). -_LIST_MARKER_SPACE_RE = re.compile(r"^(\s*-)\s{2,}") - -# Indented ATX headings from commit bodies: `` ## Title`` → `` **Title**``. -_INDENTED_ATX_HEADING_RE = re.compile(r"^(?P\s+)#{1,6}\s+(?P.*?)(?:\s+#+\s*)?$") - -# Squash-merge commit bodies often contain inner conventional-commit -# headings from the PR branch: ``* fix: thing``. After MD004 normalization -# those become ordinary list items, which makes them look like separate -# generated commits. Treat them as prose headings inside the parent entry. -_SQUASH_HEADING_RE = re.compile(r"^(?P<indent>\s*)-\s+(?P<prefix>[A-Za-z]+(?:\([^)]+\))?!?):\s+(?P<title>.+?)\s*$") - -# Changelog section headings that may appear at column zero under a version -# heading. Other accidental ``##``/``###`` headings from commit bodies are -# demoted to entry-level headings so they cannot split the generated hierarchy. -_CHANGELOG_SECTION_HEADINGS = { - "Added", - "Changed", - "Deprecated", - "Dependencies", - "Documentation", - "Fixed", - "Maintenance", - "Merged Pull Requests", - "Performance", - "Removed", - "Security", - "⚠️ Breaking Changes", -} -_ENTRY_HEADING_RE = re.compile(r"^(?P<level>#{2,6})\s+(?P<title>.*?)(?:\s+#+\s*)?$") - -# git-cliff HTML-escapes co-author email angle brackets. Markdown wants real -# angle brackets for email autolinks, otherwise rumdl treats the address as bare. -_ESCAPED_EMAIL_RE = re.compile(r"<(?P<email>[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})>", re.IGNORECASE) - -# This label set is intentionally broad, including release labels such as -# "added", "fixed", "changed", "removed", and "deprecated". Rewriting is -# only allowed when _is_isolated_body_heading accepts the line; do not relax -# that guard because tests rely on it to preserve handcrafted sub-bullets. -_SQUASH_HEADING_LABELS: dict[str, str] = { - "feat": "Added", - "fix": "Fixed", - "perf": "Performance", - "refactor": "Changed", - "test": "Changed", - "style": "Changed", - "build": "Maintenance", - "chore": "Maintenance", - "ci": "Maintenance", - "doc": "Documentation", - "docs": "Documentation", - "added": "Added", - "fixed": "Fixed", - "changed": "Changed", - "performance": "Performance", - "documentation": "Documentation", - "maintenance": "Maintenance", - "deprecated": "Deprecated", - "removed": "Removed", -} - - -def _is_normalized_squash_heading(line: str) -> bool: - """Return whether *line* is a bold heading emitted for a squash body.""" - stripped = line.strip() - return stripped.endswith("**") and any(stripped.startswith(f"**{label}: ") for label in _SQUASH_HEADING_LABELS.values()) - - -def _plain_summary(text: str) -> str: - """Return a normalized comparison key for changelog entry text.""" - text = _BREAKING_MARKER_RE.sub("", text) - text = _COMMIT_LINK_RE.sub("", text) - text = _PR_LINK_RE.sub("", text) - text = re.sub(r"^\s*[-*]\s+", "", text) - text = re.sub(r"^[A-Za-z]+(?:\([^)]+\))?!?:\s+", "", text) - return re.sub(r"\s+", " ", text).strip().casefold() - - -def _squash_heading_parts(line: str) -> tuple[str, str, str] | None: - """Return ``(indent, label, title)`` for a squash-body pseudo-heading.""" - if _COMMIT_LINK_RE.search(line): - return None - - match = _SQUASH_HEADING_RE.match(line) - if match is None: - return None - - raw_prefix = cast("str", match.group("prefix")) - kind = re.sub(r"\([^)]+\)", "", raw_prefix).rstrip("!").casefold() - label = _SQUASH_HEADING_LABELS.get(kind) - if label is None: - return None - - title = cast("str", match.group("title")).strip() - if not title: - return None - - return cast("str", match.group("indent")), label, title[0].upper() + title[1:] - - -def _normalize_squash_heading(line: str, *, nested: bool = False) -> str: - """ - Convert squash-merge pseudo-commit bullets into bold prose headings. - - This keeps release-note subsections from PR squash bodies readable while - avoiding fake top-level changelog entries. - """ - parts = _squash_heading_parts(line) - if parts is None: - return line - - indent, label, title = parts - if nested and not indent: - indent = " " - return f"{indent}**{label}: {title}**" - - -def _is_duplicate_squash_heading(line: str, parent_summary: str | None) -> bool: - """Return true when a squash-body heading repeats its parent entry.""" - parts = _squash_heading_parts(line) - if parts is None or parent_summary is None: - return False - - _, _, title = parts - return _plain_summary(title) == parent_summary - - -def _is_isolated_body_heading(lines: list[str], idx: int) -> bool: - """Return true when a body line is separated like a squash heading.""" - prev_is_blank = idx > 0 and not lines[idx - 1].strip() - next_is_blank = idx + 1 < len(lines) and not lines[idx + 1].strip() - return prev_is_blank and next_is_blank - - -def _is_squash_heading_candidate(lines: list[str], idx: int) -> bool: - """Return true when an original body line will become bold prose.""" - return _squash_heading_parts(lines[idx]) is not None and _is_isolated_body_heading(lines, idx) - - -def _max_pr_number(entry: str) -> int: - """ - Get the largest pull request number referenced in the given changelog entry. - - Returns: - highest_pr (int): The largest PR number found, or 0 if no PR links are present. - """ - numbers = [int(m) for m in _PR_LINK_RE.findall(entry)] - return max(numbers) if numbers else 0 - - -def _compact_entry(line: str, *, strip_breaking: bool = False) -> str: - """ - Produce a compact summary of a changelog list item. - - Removes a trailing commit-hash link from the given line. If `strip_breaking` is True, - also removes a single leading breaking marker. - - Parameters: - line (str): The changelog list item to compact. - strip_breaking (bool): If True, strip a single leading breaking marker. - - Returns: - str: The compacted changelog entry with the commit-hash link (and optional breaking prefix) removed. - """ - result = _COMMIT_LINK_RE.sub("", line).rstrip() - if strip_breaking: - bullet = result[:2] if result.startswith(("- ", "* ")) else "" - body = result[2:] if bullet else result - result = bullet + _BREAKING_MARKER_RE.sub("", body, count=1) - return result - - -def _append_unique(entries: list[str], entry: str) -> None: - """Append *entry* to *entries* only once, preserving first-seen order.""" - if entry not in entries: - entries.append(entry) - - -def _extract_section_summaries( - section: list[str], -) -> tuple[list[str], list[str]]: - """ - Extract summary lines for merged pull requests and breaking changes from a version section. - - Processes only top-level list items in the provided `section` (lines starting with "- " or - "* "), detects PR-linked entries and entries containing a breaking marker. Each matching line - is compacted (trailing commit-hash links removed; the breaking marker is stripped when - requested) before inclusion. - - Parameters: - section (list[str]): Lines belonging to a single version section from a changelog. - - Returns: - tuple[list[str], list[str]]: `pr_entries` — compacted lines that contain PR links; - `breaking_entries` — compacted lines marked as breaking changes. - """ - pr_entries: list[str] = [] - breaking_entries: list[str] = [] - - for sline in section: - # Only top-level list items (no leading whitespace). - if not sline.startswith(("- ", "* ")): - continue - - is_breaking = bool(_BREAKING_MARKER_RE.search(sline)) - has_pr = bool(_PR_LINK_RE.search(sline)) - - if is_breaking: - _append_unique(breaking_entries, _compact_entry(sline, strip_breaking=True)) - if has_pr: - _append_unique(pr_entries, _compact_entry(sline, strip_breaking=True)) - - return pr_entries, breaking_entries - - -def _summary_insertion_index(section: list[str]) -> int: - """Place new summaries after the version heading or an existing breaking summary.""" - if "### ⚠️ Breaking Changes" in section: - index = section.index("### ⚠️ Breaking Changes") + 1 - while index < len(section) and not section[index].startswith("### "): - index += 1 - else: - index = 1 - while index < len(section) and not section[index].strip(): - index += 1 - return index - - -def _inject_summary_sections(text: str) -> str: - """ - Insert "Merged Pull Requests" and "Breaking Changes" summary sections into a changelog text. - - Scans each version section for PR-linked list items and entries marked as breaking, - builds compact summary lists (sorted by PR number), and injects a summary block - immediately after the version heading when relevant. - - Returns: - processed_text (str): The input text with summary sections inserted; unchanged if - no version sections or no summary entries are found. - """ - lines = text.split("\n") - - # Locate version-section boundaries. - boundaries: list[int] = [] - for i, line in enumerate(lines): - if _VERSION_RE.match(line): - boundaries.append(i) - - if not boundaries: - return text - - # Walk sections in reverse so insertions don't shift later indices. - for sec_idx in reversed(range(len(boundaries))): - start = boundaries[sec_idx] - end = boundaries[sec_idx + 1] if sec_idx + 1 < len(boundaries) else len(lines) - section = lines[start:end] - - # git-cliff renders full breaking descriptions; older input may have neither summary. - has_pr_summary = "### Merged Pull Requests" in section - has_breaking_summary = "### ⚠️ Breaking Changes" in section - pr_entries, breaking_entries = _extract_section_summaries(section) - if has_pr_summary: - pr_entries = [] - if has_breaking_summary: - breaking_entries = [] - - if not pr_entries and not breaking_entries: - continue - - # Sort PRs by highest PR number, descending (newest first). - pr_entries.sort(key=_max_pr_number, reverse=True) - - insert_at = start + _summary_insertion_index(section) - - block: list[str] = [] - if breaking_entries: - block.append("### ⚠️ Breaking Changes") - block.append("") - block.extend(breaking_entries) - block.append("") - if pr_entries: - block.append("### Merged Pull Requests") - block.append("") - block.extend(pr_entries) - block.append("") - - lines[insert_at:insert_at] = block - - return "\n".join(lines) - - -def _reflow_line(line: str, max_width: int = MAX_LINE_WIDTH) -> str: - """ - Reflow a single markdown line to fit within max_width while preserving atomic markdown tokens. - - Preserves a leading list marker ("- " or "* ") on the first line and indents continuation - lines to maintain list nesting. Tokens such as links and code spans are kept intact and not - split across lines. - - Parameters: - line (str): The original line to reflow. - max_width (int): Maximum allowed line width; lines longer than this will be wrapped. - - Returns: - str: The reflowed line, potentially containing newline characters so that no output line exceeds max_width. - """ - if len(line) <= max_width: - return line - - stripped = line.lstrip() - indent = line[: len(line) - len(stripped)] - - # Determine first-line prefix vs continuation indent. - if stripped.startswith(("- ", "* ")): - first_prefix = indent + stripped[:2] - content = stripped[2:] - cont_indent = indent + " " - else: - first_prefix = indent - content = stripped - cont_indent = indent - - tokens = _markdown_tokens(content) - if not tokens: - return line - - lines: list[str] = [] - current = first_prefix + tokens[0] - - for token in tokens[1:]: - candidate = current + " " + token - if len(candidate) <= max_width: - current = candidate - else: - lines.append(current) - current = cont_indent + token - - lines.append(current) - return "\n".join(lines) - - -def _deindent_orphan(line: str, lines: list[str], idx: int) -> str: - """ - Normalize indentation for sub-bullet list items produced by git-cliff. - - Cliff's ``indent(prefix=" ")`` filter can compound with pre-existing - indentation in commit bodies, producing non-standard nesting depths. - This function scans backward through the original *lines* to find the - nearest list ancestor and normalizes the indent to ``parent + 2`` - spaces (MD007). - """ - stripped = line.lstrip() - if not (line.startswith(" ") and stripped.startswith("- ")): - return line - - our_indent = len(line) - len(stripped) - nearest_parent_indent: int | None = None - - for j in range(idx - 1, -1, -1): - prev = lines[j] - if not prev.strip(): - continue # skip blanks - if prev.startswith(" "): - prev_stripped = prev.lstrip() - if prev_stripped.startswith(("- ", "* ")): - if _is_squash_heading_candidate(lines, j): - continue - parent_indent = len(prev) - len(prev_stripped) - if our_indent > parent_indent and nearest_parent_indent is None: - nearest_parent_indent = parent_indent - continue # skip cliff-indented content - # A normalized squash heading is a prose parent for its body bullets. - # Preserve that relationship on subsequent post-processing runs. - if _is_normalized_squash_heading(prev): - return " " + stripped - # Column-0 non-blank line — determines final result. - is_list_parent = prev.startswith(("- ", "* ")) - if is_list_parent: - base = nearest_parent_indent + 2 if nearest_parent_indent is not None else 2 - return " " * base + stripped - # Column-0 non-list — orphan. - return line[2:] if nearest_parent_indent is not None else stripped - # Reached top of document — orphan. - return line[2:] if nearest_parent_indent is not None else stripped - - -def _normalize_list_continuation_indent(line: str, lines: list[str], idx: int) -> str: - """ - Normalize generated list continuation indentation. - - git-cliff commit bodies can contain pre-indented prose under a list item. - Markdown treats that prose as list-continuation content, where rumdl's MD077 - expects exactly two spaces past the parent bullet indentation. - """ - stripped = line.lstrip() - if not line.startswith(" ") or not stripped or stripped.startswith(("- ", "* ")): - return line - - our_indent = len(line) - len(stripped) - - for j in range(idx - 1, -1, -1): - prev = lines[j] - if not prev.strip(): - continue - - prev_stripped = prev.lstrip() - if prev_stripped.startswith(("- ", "* ")): - parent_indent = len(prev) - len(prev_stripped) - expected_indent = parent_indent + 2 - if our_indent > expected_indent: - return " " * expected_indent + stripped - return line - - if not prev.startswith(" "): - return line - - return line - - -def _list_item_indent(line: str) -> int | None: - """Return the indentation of a Markdown list item, if *line* is one.""" - stripped = line.lstrip() - if not stripped.startswith(("- ", "* ")): - return None - return len(line) - len(stripped) - - -def _has_previous_peer_list_item(lines: list[str], idx: int, peer_indent: int) -> bool: - """Return true if a prior list item exists at *peer_indent* before *idx*.""" - for j in range(idx - 1, -1, -1): - prev = lines[j] - if not prev.strip(): - continue - - prev_indent = _list_item_indent(prev) - if prev_indent == peer_indent: - return True - if prev_indent is not None or prev.startswith(" "): - continue - return False - - return False - - -def _next_list_item_indent(lines: list[str], idx: int) -> int | None: - """Return the next nonblank line's list-item indentation, if it is a list item.""" - for next_line in lines[idx + 1 :]: - if not next_line.strip(): - continue - return _list_item_indent(next_line) - return None - - -def _is_blank_between_peer_list_items(lines: list[str], idx: int) -> bool: - """Return true when a blank line separates adjacent items in the same list.""" - if lines[idx].strip(): - return False - - next_indent = _next_list_item_indent(lines, idx) - if next_indent is None: - return False - - return _has_previous_peer_list_item(lines, idx, next_indent) - - -def _normalize_email_autolinks(line: str) -> str: - """Convert git-cliff's escaped email autolinks into Markdown autolinks.""" - return _ESCAPED_EMAIL_RE.sub(r"<\g<email>>", line) - - -def _needs_blank_before(line: str, lines: list[str], idx: int, result: list[str]) -> bool: - """ - Determine whether a blank line is required before a list item to satisfy Markdown rule MD032. - - Parameters: - line (str): The current line. - lines (list[str]): The source lines being post-processed. - idx (int): The index of the current line in ``lines``. - result (list[str]): The lines already emitted immediately before the current line. - - Returns: - bool: `True` if a blank line should be inserted before the list item, `False` otherwise. - """ - stripped = line.lstrip() - if not stripped.startswith("- ") or not result or not result[-1].strip(): - return False - - prev = result[-1].lstrip() - if prev.startswith("#"): - return False - if prev.startswith("- "): - return False - - current_indent = len(line) - len(stripped) - return not _has_previous_peer_list_item(result, len(result), current_indent) - - -def _normalize_indented_heading(line: str) -> str: - """ - Convert indented commit-body headings into bold prose. - - git-cliff indents commit bodies under each changelog entry. If a historical - commit body contains an ATX heading such as ``## Correctness Fixes``, the - rendered changelog contains `` ## Correctness Fixes``. rumdl still treats - that as a heading, but MD023 requires headings to start at column 0. - Keeping the text as bold prose preserves readability without changing the - generated changelog hierarchy. - """ - match = _INDENTED_ATX_HEADING_RE.match(line) - if match is None: - return line - - title = match.group("title").strip() - if not title: - return line - - return f"{match.group('indent')}**{title}**" - - -def _normalize_entry_heading(line: str) -> str: - """Demote accidental column-zero commit-body headings to entry headings.""" - match = _ENTRY_HEADING_RE.match(line) - if match is None: - return line - - level = match.group("level") - title = match.group("title").strip() - if not title or level.startswith("####"): - return line - if level == "##" and (_VERSION_RE.match(line) or title == "Archives"): - return line - if level == "###" and title in _CHANGELOG_SECTION_HEADINGS: - return line - return f"#### {title}" - - -def normalize_entry_headings_text(text: str) -> str: - """Normalize accidental entry headings in an existing changelog document.""" - result: list[str] = [] - active_fence: _CodeFence | None = None - - for line in text.split("\n"): - if active_fence is not None: - result.append(line) - if _closes_code_fence(line, active_fence): - active_fence = None - continue - - active_fence = _opening_code_fence(line) - if active_fence is not None: - result.append(line) - continue - result.append(_normalize_entry_heading(line)) - - return "\n".join(result).rstrip("\n") + "\n" - - -def _fence_parts(line: str) -> tuple[str, str, str] | None: - """Return indentation, delimiter run, and info string for a fence line.""" - match = _FENCE_RE.fullmatch(line) - if match is None: - return None - return ( - cast("str", match.group("indent")), - cast("str", match.group("fence")), - cast("str", match.group("info")), - ) - - -def _opening_code_fence(line: str) -> _CodeFence | None: - """Parse an opening backtick or tilde fence.""" - parts = _fence_parts(line) - if parts is None: - return None - _, delimiter_run, info = parts - delimiter = delimiter_run[0] - if delimiter == "`" and "`" in info: - return None - return _CodeFence(delimiter=delimiter, length=len(delimiter_run)) - - -def _closes_code_fence(line: str, active_fence: _CodeFence) -> bool: - """Return whether *line* validly closes *active_fence*.""" - parts = _fence_parts(line) - if parts is None: - return False - _, delimiter_run, info = parts - return delimiter_run[0] == active_fence.delimiter and len(delimiter_run) >= active_fence.length and not info.strip() - - -def _process_code_fence( - line: str, - result: list[str], - active_fence: _CodeFence | None, - next_line: str | None, -) -> tuple[bool, _CodeFence | None]: - """Handle fenced-code transitions and append the line when consumed.""" - if active_fence is None: - active_fence = _opening_code_fence(line) - if active_fence is None: - return False, None - # MD031: blank line before fenced code block. - if result and result[-1].strip(): - result.append("") - # MD040: add language tag if missing. - parts = _fence_parts(line) - if parts is not None: - indent, delimiter_run, info = parts - if not info.strip(): - line = f"{indent}{delimiter_run}text" - result.append(line) - return True, active_fence - - if not _closes_code_fence(line, active_fence): - return False, active_fence - - result.append(line) - if next_line is not None and next_line.strip(): - result.append("") - return True, None - - -def _update_entry_summary(line: str, current_entry_summary: str | None) -> str | None: - """Track the active changelog entry summary for squash-body cleanup.""" - if line.startswith("- ") and _COMMIT_LINK_RE.search(line): - return _plain_summary(line) - if line.startswith(("### ", "## ", "# ")): - return None - return current_entry_summary - - -def _should_skip_duplicate_heading( - line: str, - result: list[str], - current_entry_summary: str | None, - is_isolated_body_heading: bool, -) -> tuple[bool, bool]: - """Return whether to skip a duplicate squash heading and the following blank.""" - if is_isolated_body_heading and _is_duplicate_squash_heading(line, current_entry_summary): - return True, bool(result and not result[-1].strip()) - return False, False - - -def _normalize_body_line(line: str, lines: list[str], idx: int, result: list[str], current_entry_summary: str | None) -> str: - """Apply markdown hygiene transforms to a non-code line.""" - is_isolated_body_heading = _is_isolated_body_heading(lines, idx) - line = _deindent_orphan(line, lines, idx) - line = _normalize_list_continuation_indent(line, lines, idx) - line = _normalize_indented_heading(line) - line = _normalize_entry_heading(line) - - if is_isolated_body_heading: - line = _normalize_squash_heading(line, nested=current_entry_summary is not None) - - if _needs_blank_before(line, lines, idx, result): - result.append("") - - return _reflow_line(line) if len(line) > MAX_LINE_WIDTH else line - - -def _dependabot_metadata_end(lines: list[str], separator_index: int) -> int | None: - """Return the closing marker index for an unfenced Dependabot footer.""" - metadata_start = separator_index + 1 - while metadata_start < len(lines) and not lines[metadata_start].strip(): - metadata_start += 1 - if metadata_start >= len(lines) or lines[metadata_start].strip() != "updated-dependencies:": - return None - - metadata_end = metadata_start + 1 - while metadata_end < len(lines) and lines[metadata_end].strip() != "..." and _opening_code_fence(lines[metadata_end]) is None: - metadata_end += 1 - if metadata_end >= len(lines) or lines[metadata_end].strip() != "...": - return None - return metadata_end - - -def _strip_dependabot_metadata(text: str) -> str: - """Remove Dependabot's YAML metadata footer from rendered commit bodies.""" - lines = text.split("\n") - result: list[str] = [] - active_fence: _CodeFence | None = None - idx = 0 - - while idx < len(lines): - if active_fence is not None: - result.append(lines[idx]) - if _closes_code_fence(lines[idx], active_fence): - active_fence = None - idx += 1 - continue - - active_fence = _opening_code_fence(lines[idx]) - if active_fence is not None: - result.append(lines[idx]) - idx += 1 - continue - - metadata_end = _dependabot_metadata_end(lines, idx) if lines[idx].strip() == "---" else None - if metadata_end is None: - result.append(lines[idx]) - idx += 1 - continue - - while result and not result[-1].strip(): - result.pop() - idx = metadata_end + 1 - while idx < len(lines) and not lines[idx].strip(): - idx += 1 - if result and idx < len(lines): - result.append("") - - return "\n".join(result) - - -def _escape_breaking_prose(text: str) -> str: - """Escape HTML in breaking descriptions while preserving Markdown code.""" - result: list[str] = [] - in_breaking = False - active_fence: _CodeFence | None = None - for line in text.split("\n"): - if active_fence is not None: - result.append(line) - if _closes_code_fence(line, active_fence): - active_fence = None - continue - if line.startswith(("## ", "### ")): - in_breaking = line == "### ⚠️ Breaking Changes" - active_fence = _opening_code_fence(line) - if not in_breaking or active_fence is not None: - result.append(line) - continue - position = 0 - escaped: list[str] = [] - while position < len(line): - end = _backtick_span_end(line, position) if line[position] == "`" else None - if end is not None: - escaped.append(line[position:end]) - position = end - else: - escaped.append({"<": "<", ">": ">"}.get(line[position], line[position])) - position += 1 - result.append("".join(escaped)) - return "\n".join(result) - - -def postprocess_text(text: str) -> str: - """Apply changelog markdown hygiene transforms to *text*.""" - text = _strip_dependabot_metadata(text) - text = _escape_breaking_prose(text) - - # Inject PR / breaking-change summary sections before reflow. - text = _inject_summary_sections(text) - - lines = text.split("\n") - result: list[str] = [] - active_fence: _CodeFence | None = None - current_entry_summary: str | None = None - drop_next_blank = False - - for idx, raw_line in enumerate(lines): - line = raw_line - - # --- fenced code-block tracking --- - next_line = lines[idx + 1] if idx + 1 < len(lines) else None - handled, active_fence = _process_code_fence(line, result, active_fence, next_line) - if handled: - continue - - # Never reflow inside code blocks. - if active_fence is not None: - result.append(line) - continue - - if _is_blank_between_peer_list_items(lines, idx): - continue - - # --- MD004: normalise ``* `` list markers to ``- `` --- - line = _STAR_LIST_RE.sub(r"\1- ", line) - - # --- MD030: normalise spaces after list marker --- - line = _LIST_MARKER_SPACE_RE.sub(r"\1 ", line) - line = _normalize_email_autolinks(line) - - current_entry_summary = _update_entry_summary(line, current_entry_summary) - is_isolated_body_heading = _is_isolated_body_heading(lines, idx) - - # --- GitHub squash bodies: collapse duplicate pseudo-headings --- - should_skip, next_drop_blank = _should_skip_duplicate_heading( - line, - result, - current_entry_summary, - is_isolated_body_heading, - ) - if should_skip: - drop_next_blank = next_drop_blank - continue - if drop_next_blank and not line.strip(): - drop_next_blank = False - continue - drop_next_blank = False - - result.append(_normalize_body_line(line, lines, idx, result, current_entry_summary)) - - # 1. Reassemble and strip trailing blank lines. - text = "\n".join(result) - return text.rstrip("\n") + "\n" - - -def _write_text_atomic(path: Path, text: str) -> None: - """Replace an existing UTF-8 file atomically while preserving its mode.""" - mode = stat.S_IMODE(path.stat().st_mode) - staged: Path | None = None - try: - with tempfile.NamedTemporaryFile( - "w", - encoding="utf-8", - newline="", - dir=path.parent, - prefix=f".{path.name}.", - suffix=".tmp", - delete=False, - ) as handle: - staged = Path(handle.name) - handle.write(text) - handle.flush() - os.fsync(handle.fileno()) - staged.chmod(mode) - staged.replace(path) - staged = None - finally: - if staged is not None: - staged.unlink(missing_ok=True) - - -def postprocess(path: Path) -> None: - """Read *path*, apply hygiene fixes, and write it back.""" - text = path.read_text(encoding="utf-8") - text = postprocess_text(text) - - _write_text_atomic(path, text) - - -def main() -> None: - """CLI entry point for ``postprocess-changelog``.""" - parser = argparse.ArgumentParser( - prog="postprocess-changelog", - description="Apply markdown hygiene to a git-cliff generated CHANGELOG.md.", - ) - parser.add_argument( - "path", - nargs="?", - default="CHANGELOG.md", - help="Path to CHANGELOG.md (default: CHANGELOG.md)", - ) - args = parser.parse_args() - - changelog = Path(args.path) - if not changelog.is_file(): - print(f"Error: {changelog} not found", file=sys.stderr) - sys.exit(1) - - postprocess(changelog) - - -if __name__ == "__main__": - main() diff --git a/scripts/tag_release.py b/scripts/tag_release.py deleted file mode 100755 index bc33164..0000000 --- a/scripts/tag_release.py +++ /dev/null @@ -1,436 +0,0 @@ -#!/usr/bin/env -S uv run -"""Create annotated git tags from CHANGELOG.md sections. - -Handles GitHub's 125KB tag-annotation size limit by falling back to a short -reference message when the changelog section is too large. - -Usage: - tag-release v1.2.3 # create annotated tag from CHANGELOG.md - tag-release v1.2.3 --force # recreate tag if it already exists - tag-release v1.2.3 --debug # verbose output - -Ported from the delaunay project's changelog_utils.py (tag-creation subset). -""" - -import argparse -import logging -import re -import subprocess -import sys -import tomllib -from pathlib import Path -from urllib.parse import urlsplit - -from subprocess_utils import ( - ExecutableNotFoundError, - format_exception_diagnostics, - run_git_command, - run_git_command_with_input, -) - -# GitHub's maximum size for git tag annotations (bytes) -_GITHUB_TAG_ANNOTATION_LIMIT = 125_000 - -# ANSI color codes for terminal output -_GREEN = "\033[0;32m" -_BLUE = "\033[0;34m" -_YELLOW = "\033[1;33m" -_RESET = "\033[0m" - -log = logging.getLogger(__name__) - -_GITHUB_SCP_REMOTE_RE = re.compile(r"^git@github\.com:(?P<path>.+)$", re.IGNORECASE) -_GITHUB_REPO_COMPONENT_RE = re.compile(r"^[A-Za-z0-9_.-]+$") - - -# --------------------------------------------------------------------------- -# SemVer validation -# --------------------------------------------------------------------------- - -# SemVer 2.0.0 strict with required 'v' prefix -# Alphanumeric prerelease identifier: any [0-9A-Za-z-]+ containing at least one -# non-digit. This permits identifiers like "1a" that start with a digit but are -# not purely numeric (SemVer 2.0.0 §9). -_ALNUM_ID = r"(?:(?=[0-9A-Za-z-]*[A-Za-z-])[0-9A-Za-z-]+)" -_SEMVER_RE = re.compile( - r"^v" - r"(0|[1-9]\d*)\." - r"(0|[1-9]\d*)\." - r"(0|[1-9]\d*)" - rf"(?:-(?:(?:0|[1-9]\d*)|{_ALNUM_ID})" - rf"(?:\.(?:(?:0|[1-9]\d*)|{_ALNUM_ID}))*" - r")?" - r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" -) - - -def validate_semver(tag_version: str) -> None: - """Raise ``ValueError`` if *tag_version* is not valid ``vX.Y.Z`` SemVer.""" - if not _SEMVER_RE.match(tag_version): - msg = f"Tag version should follow SemVer format 'vX.Y.Z' (e.g., v0.3.5, v1.2.3-rc.1). Got: {tag_version}" - raise ValueError(msg) - - -def parse_version(tag_version: str) -> str: - """Return version string without leading ``v``.""" - return tag_version.removeprefix("v") - - -# --------------------------------------------------------------------------- -# Changelog helpers -# --------------------------------------------------------------------------- - - -def find_changelog(start: Path | None = None) -> Path: - """Locate ``CHANGELOG.md`` in *start* or its parent. - - Raises: - FileNotFoundError: If ``CHANGELOG.md`` cannot be found. - """ - base = start or Path.cwd() - for candidate in (base / "CHANGELOG.md", base.parent / "CHANGELOG.md"): - if candidate.is_file(): - return candidate - msg = "CHANGELOG.md not found in current directory or parent directory." - raise FileNotFoundError(msg) - - -def _package_version(changelog: Path) -> str: - """Return the authoritative Cargo package version beside the changelog.""" - cargo_toml = changelog.parent / "Cargo.toml" - try: - data = tomllib.loads(cargo_toml.read_text(encoding="utf-8")) - except tomllib.TOMLDecodeError as exc: - msg = f"Could not parse {cargo_toml}: {exc}" - raise ValueError(msg) from exc - package = data.get("package") - if not isinstance(package, dict): - msg = f"{cargo_toml} does not define a [package] table" - raise TypeError(msg) - version = package.get("version") - if not isinstance(version, str) or not version.strip(): - msg = f"{cargo_toml} does not define a non-empty package version" - raise TypeError(msg) - return version - - -def _validated_release_target(tag_version: str) -> tuple[str, Path]: - """Validate a requested tag and return its version plus changelog path.""" - validate_semver(tag_version) - version = parse_version(tag_version) - changelog = find_changelog() - package_version = _package_version(changelog) - if version != package_version: - msg = f"Tag version {version!r} does not match Cargo package version {package_version!r}" - raise ValueError(msg) - return version, changelog - - -def _archive_path_for_version(changelog: Path, version: str) -> Path | None: - """Return the archive file path for *version* if it exists.""" - parts = version.split(".") - if len(parts) < 2: - return None - minor = f"{parts[0]}.{parts[1]}" - candidate = changelog.parent / "docs" / "archive" / "changelog" / f"{minor}.md" - return candidate if candidate.is_file() else None - - -def _extract_section_from_file(path: Path, version: str) -> str | None: - """Try to extract the changelog section for *version* from *path*.""" - header_re = _version_header_re(version) - content = path.read_text(encoding="utf-8") - - lines = content.split("\n") - section: list[str] = [] - collecting = False - - for line in lines: - if re.match(r"^##\s", line): - if collecting: - break - if header_re.match(line): - collecting = True - continue - elif collecting: - section.append(line) - - if not collecting: - return None - - start = 0 - while start < len(section) and not section[start].strip(): - start += 1 - end = len(section) - while end > start and not section[end - 1].strip(): - end -= 1 - - return "\n".join(section[start:end]) - - -def extract_changelog_section(changelog: Path, version: str) -> tuple[str, Path]: - """Extract the changelog body for *version* (without ``v`` prefix). - - Searches the root changelog first, then falls back to the per-minor - archive file under ``docs/archive/changelog``. - - Raises: - LookupError: If the version section is not found or empty. - """ - body = _extract_section_from_file(changelog, version) - if body is not None: - if not body.strip(): - msg = f"Changelog section for version {version} is empty." - raise LookupError(msg) - return body, changelog - - archive = _archive_path_for_version(changelog, version) - if archive: - body = _extract_section_from_file(archive, version) - if body is not None: - if not body.strip(): - msg = f"Changelog section for version {version} is empty." - raise LookupError(msg) - return body, archive - - msg = f"No changelog section found for version {version}. Expected a heading like: ## [{version}] - YYYY-MM-DD" - raise LookupError(msg) - - -# --------------------------------------------------------------------------- -# Git helpers -# --------------------------------------------------------------------------- - - -def _tag_exists(tag_version: str) -> bool: - """Return ``True`` if *tag_version* already exists as a git tag.""" - try: - run_git_command(["rev-parse", "-q", "--verify", f"refs/tags/{tag_version}"]) - except subprocess.CalledProcessError: - return False - else: - return True - - -def _delete_tag(tag_version: str) -> None: - run_git_command(["tag", "-d", tag_version]) - - -def _github_repo_url(path: str) -> str: - """Return a canonical GitHub URL for a validated two-component path.""" - normalized = path.strip("/").removesuffix(".git") - components = normalized.split("/") - if ( - len(components) != 2 - or any(component in {"", ".", ".."} for component in components) - or any(_GITHUB_REPO_COMPONENT_RE.fullmatch(component) is None for component in components) - ): - msg = "Origin remote must identify exactly one GitHub owner and repository." - raise ValueError(msg) - return f"https://github.com/{components[0]}/{components[1]}" - - -def _get_repo_url() -> str: - """Detect and validate the public GitHub HTTPS URL for ``origin``.""" - result = run_git_command(["remote", "get-url", "origin"]) - raw = result.stdout.strip() - if not raw or any(character.isspace() for character in raw): - msg = "Origin remote must be a GitHub HTTPS or SSH repository URL." - raise ValueError(msg) - - scp_match = _GITHUB_SCP_REMOTE_RE.fullmatch(raw) - if scp_match is not None: - path = scp_match.group("path") - if "?" in path or "#" in path: - msg = "Origin remote must not include query parameters or fragments." - raise ValueError(msg) - return _github_repo_url(path) - - try: - parsed = urlsplit(raw) - hostname = parsed.hostname - port = parsed.port - except ValueError as err: - msg = "Origin remote must be a GitHub HTTPS or SSH repository URL." - raise ValueError(msg) from err - - if parsed.query or parsed.fragment: - msg = "Origin remote must not include query parameters or fragments." - raise ValueError(msg) - if parsed.password is not None or (parsed.scheme == "https" and parsed.username is not None): - msg = "Origin remote must not contain credentials." - raise ValueError(msg) - if parsed.scheme == "ssh" and parsed.username not in {None, "git"}: - msg = "Origin remote must not contain credentials other than the standard SSH user." - raise ValueError(msg) - if ( - parsed.scheme not in {"https", "ssh"} - or hostname is None - or hostname.casefold() != "github.com" - or port is not None - or (parsed.scheme == "ssh" and parsed.username != "git") - ): - msg = "Origin remote must be a GitHub HTTPS or SSH repository URL." - raise ValueError(msg) - - return _github_repo_url(parsed.path) - - -def _version_header_re(version: str) -> re.Pattern[str]: - """Build the header regex for *version*, matching ``extract_changelog_section``.""" - return re.compile(rf"^##\s*\[?v?{re.escape(version)}\]?(?:$|\s|\()") - - -def _heading_to_anchor(heading_line: str) -> str: - """Convert a markdown heading line to a GitHub-compatible anchor slug.""" - heading = heading_line.removeprefix("## ").strip() - heading = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", heading) - heading = re.sub(r"\[([^\]]+)\]", r"\1", heading) - heading = heading.lower() - heading = re.sub(r"[^a-z0-9\s-]", "", heading) - return re.sub(r"\s+", "-", heading) - - -def _find_anchor_in_file(path: Path, version: str) -> str | None: - """Search *path* for a version heading and return its GitHub anchor.""" - header_re = _version_header_re(version) - try: - for line in path.read_text(encoding="utf-8").splitlines(): - if header_re.match(line): - return _heading_to_anchor(line) - except OSError: - pass - return None - - -def _github_anchor(changelog: Path, version: str) -> str: - """Build a GitHub-compatible heading anchor (matches ``github-slugger``).""" - anchor = _find_anchor_in_file(changelog, version) - if anchor: - return anchor - - archive = _archive_path_for_version(changelog, version) - if archive: - anchor = _find_anchor_in_file(archive, version) - if anchor: - return anchor - - return re.sub(r"[^a-z0-9-]", "", f"v{version}".lower()) - - -# --------------------------------------------------------------------------- -# Core workflow -# --------------------------------------------------------------------------- - - -def create_tag(tag_version: str, *, force: bool = False) -> None: - """Create an annotated git tag with changelog content. - - If the changelog section exceeds GitHub's 125KB limit, creates the tag - with a short reference message instead. - """ - version, changelog = _validated_release_target(tag_version) - - # Check for existing tag (but don't delete yet — validate first) - tag_existed = _tag_exists(tag_version) - if tag_existed and not force: - print(f"{_YELLOW}Tag '{tag_version}' already exists.{_RESET}", file=sys.stderr) - print(f"Use --force to recreate, or delete manually: git tag -d {tag_version}", file=sys.stderr) - sys.exit(1) - - # Extract changelog section (before any mutation) - section, source = extract_changelog_section(changelog, version) - section_bytes = len(section.encode("utf-8")) - - # Check size limit - if section_bytes > _GITHUB_TAG_ANNOTATION_LIMIT: - print(f"{_YELLOW}⚠ Changelog section ({section_bytes:,} bytes) exceeds GitHub's tag limit ({_GITHUB_TAG_ANNOTATION_LIMIT:,} bytes){_RESET}") - anchor = _github_anchor(changelog, version) - repo_url = _get_repo_url() - try: - source_rel = source.relative_to(changelog.parent) - except ValueError: - source_rel = source - source_rel_posix = source_rel.as_posix() - tag_message = ( - f"Version {version}\n\n" - f"This release contains extensive changes. See full changelog:\n" - f"<{repo_url}/blob/{tag_version}/{source_rel_posix}#{anchor}>\n\n" - f"For detailed release notes, refer to {source_rel_posix} in the repository.\n" - ) - is_truncated = True - print(f"{_BLUE}→ Creating annotated tag with CHANGELOG.md reference{_RESET}") - else: - tag_message = section - is_truncated = False - print(f"{_BLUE}Tag message preview ({section_bytes:,} bytes):{_RESET}") - preview = section.split("\n")[:20] - print("----------------------------------------") - print("\n".join(preview)) - if len(section.split("\n")) > 20: - print("... (truncated for preview)") - print("----------------------------------------") - - # Create annotated tag. Let git replace the ref transactionally instead of - # deleting the existing tag before the replacement object exists. - label = "reference" if is_truncated else "full changelog" - tag_command = ["tag", "-a", tag_version, "-F", "-", "--cleanup=verbatim"] - if tag_existed and force: - print(f"{_BLUE}Replacing existing tag '{tag_version}'...{_RESET}") - tag_command.insert(1, "-f") - - print(f"{_BLUE}Creating annotated tag '{tag_version}' with {label} content...{_RESET}") - run_git_command_with_input(tag_command, input_data=tag_message) - - # Success - print(f"{_GREEN}✓ Successfully created tag '{tag_version}'{_RESET}") - print() - print("Next steps:") - if force: - print(f" 1. Force-push the tag: {_BLUE}git push --force origin {tag_version}{_RESET}") - else: - print(f" 1. Push the tag: {_BLUE}git push origin {tag_version}{_RESET}") - print(f" 2. Create GitHub release: {_BLUE}gh release create {tag_version} --title {tag_version} --notes-from-tag{_RESET}") - if is_truncated: - print(f"\n{_YELLOW}Note: Tag annotation references CHANGELOG.md due to size (>125KB).{_RESET}") - - -# --------------------------------------------------------------------------- -# CLI entry point -# --------------------------------------------------------------------------- - - -def main(argv: list[str] | None = None) -> int: - """CLI entry point for ``tag-release``.""" - parser = argparse.ArgumentParser( - prog="tag-release", - description="Create an annotated git tag from a CHANGELOG.md section.", - ) - parser.add_argument("version", help="Tag version (e.g. v1.2.3)") - parser.add_argument("--force", action="store_true", help="Recreate tag if it already exists") - parser.add_argument("--debug", action="store_true", help="Enable debug logging") - args = parser.parse_args(argv) - - if args.debug: - logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s") - else: - logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s") - - try: - create_tag(args.version, force=args.force) - except ( - TypeError, - ValueError, - FileNotFoundError, - LookupError, - ExecutableNotFoundError, - subprocess.CalledProcessError, - subprocess.TimeoutExpired, - ) as exc: - print(f"Error: {format_exception_diagnostics(exc)}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/tests/test_archive_changelog.py b/scripts/tests/test_archive_changelog.py deleted file mode 100644 index 08efed7..0000000 --- a/scripts/tests/test_archive_changelog.py +++ /dev/null @@ -1,715 +0,0 @@ -"""Tests for archive_changelog.py — parsing, grouping, split/archive, and idempotency.""" - -import logging -from typing import TYPE_CHECKING - -import pytest - -import archive_changelog as archive_changelog_module -from archive_changelog import ( - _extract_link_defs, - _format_link_defs, - _minor_key, - _version_sort_key, - archive_changelog, - build_root, - group_by_minor, - parse_changelog, - write_archive, -) -from tag_release import _github_anchor, extract_changelog_section - -if TYPE_CHECKING: - from pathlib import Path - - -# --------------------------------------------------------------------------- -# Fixtures / helpers -# --------------------------------------------------------------------------- - -_PREAMBLE = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n" - -_UNRELEASED = "## [Unreleased]\n\n### Added\n\n- Something new\n\n" - -_V072 = "## [0.7.2] - 2026-03-10\n\n### Fixed\n\n- Bug fix in 0.7.2\n\n" - -_V071 = "## [0.7.1] - 2026-02-20\n\n### Changed\n\n- Change in 0.7.1\n\n" - -_V062 = "## [0.6.2] - 2026-01-01\n\n### Maintenance\n\n- Bump dep in 0.6.2\n\n" - -_V061 = "## [0.6.1] - 2025-12-17\n\n### Added\n\n- Feature in 0.6.1\n\n" - -_V020 = "## [0.2.0] - 2024-09-13\n\n### Added\n\n- Initial release\n" - -_LINK_DEFS = ( - "\n" - "[unreleased]: https://github.com/acgetchell/delaunay/compare/v0.7.2..HEAD\n" - "[0.7.2]: https://github.com/acgetchell/delaunay/compare/v0.7.1..v0.7.2\n" - "[0.7.1]: https://github.com/acgetchell/delaunay/compare/v0.7.0..v0.7.1\n" - "[0.6.2]: https://github.com/acgetchell/delaunay/compare/v0.6.1..v0.6.2\n" - "[0.6.1]: https://github.com/acgetchell/delaunay/compare/v0.6.0..v0.6.1\n" - "[0.2.0]: https://github.com/acgetchell/delaunay/tree/v0.2.0\n" -) - - -def _full_changelog() -> str: - return _PREAMBLE + _UNRELEASED + _V072 + _V071 + _V062 + _V061 + _V020 - - -def _full_changelog_with_links() -> str: - return _full_changelog() + _LINK_DEFS - - -# --------------------------------------------------------------------------- -# Unit tests -# --------------------------------------------------------------------------- - - -class TestMinorKey: - def test_simple(self) -> None: - assert _minor_key("0.7.2") == "0.7" - - def test_prerelease(self) -> None: - assert _minor_key("1.2.3-rc.1") == "1.2" - - def test_major(self) -> None: - assert _minor_key("2.0.0") == "2.0" - - def test_malformed_single_component(self) -> None: - with pytest.raises(ValueError, match="at least two components"): - _minor_key("1") - - def test_malformed_empty_string(self) -> None: - with pytest.raises(ValueError, match="at least two components"): - _minor_key("") - - -class TestVersionSortKey: - def test_numeric_ordering(self) -> None: - labels = ["0.2.0", "0.10.0", "0.9.0", "0.7.2"] - assert sorted(labels, key=_version_sort_key) == [ - "0.2.0", - "0.7.2", - "0.9.0", - "0.10.0", - ] - - def test_minor_keys(self) -> None: - minors = ["0.2", "0.10", "0.9", "0.7"] - assert sorted(minors, key=_version_sort_key, reverse=True) == [ - "0.10", - "0.9", - "0.7", - "0.2", - ] - - def test_unreleased_sorts_last(self) -> None: - labels = ["0.7.2", "unreleased", "0.6.1"] - assert sorted(labels, key=_version_sort_key) == [ - "0.6.1", - "0.7.2", - "unreleased", - ] - - def test_reverse_unreleased_first(self) -> None: - labels = ["0.7.2", "unreleased", "0.6.1"] - assert sorted(labels, key=_version_sort_key, reverse=True) == [ - "unreleased", - "0.7.2", - "0.6.1", - ] - - def test_prerelease_labels_stay_semantic(self) -> None: - labels = ["1.2.3", "1.2.3-rc.10", "1.2.3-rc.2", "1.2.3-alpha.1", "unreleased"] - assert sorted(labels, key=_version_sort_key) == [ - "1.2.3-alpha.1", - "1.2.3-rc.2", - "1.2.3-rc.10", - "1.2.3", - "unreleased", - ] - - def test_build_metadata_is_ignored_for_sorting(self) -> None: - labels = ["1.2.3-rc.1+build.7", "1.2.3+build.7", "1.2.3-alpha.1+build.7"] - assert sorted(labels, key=_version_sort_key) == [ - "1.2.3-alpha.1+build.7", - "1.2.3-rc.1+build.7", - "1.2.3+build.7", - ] - - -class TestParseChangelog: - def test_splits_preamble_unreleased_versions(self) -> None: - preamble, unreleased, blocks = parse_changelog(_full_changelog()) - assert "# Changelog" in preamble - assert "Unreleased" in unreleased - assert len(blocks) == 5 - assert blocks[0][0] == "0.7.2" - assert blocks[-1][0] == "0.2.0" - - def test_no_headings(self) -> None: - preamble, unreleased, blocks = parse_changelog("Just some text\n") - assert preamble == "Just some text\n" - assert unreleased == "" - assert blocks == [] - - def test_no_unreleased(self) -> None: - text = _PREAMBLE + _V072 + _V071 - _, unreleased, blocks = parse_changelog(text) - assert unreleased == "" - assert len(blocks) == 2 - - def test_rejects_non_semver_headings(self) -> None: - text = _PREAMBLE + _V072 + "## [CustomLabel]\n\n- Something\n\n" + _V071 - - with pytest.raises(ValueError, match="Unrecognized changelog version heading"): - parse_changelog(text) - - def test_rejects_unreleased_heading_without_closing_bracket_boundary(self) -> None: - text = _PREAMBLE + "## [Unreleased]invalid\n\n- Something\n\n" + _V072 - - with pytest.raises(ValueError, match="Unrecognized changelog version heading"): - parse_changelog(text) - - @pytest.mark.parametrize("version", ["01.2.3", "1.02.3", "1.2.03", "1.2.3garbage", "1.2.3-01"]) - def test_rejects_malformed_semver_headings(self, version: str) -> None: - text = _PREAMBLE + f"## [{version}] - 2026-01-01\n" - - with pytest.raises(ValueError, match="semantic version"): - parse_changelog(text) - - def test_rejects_duplicate_unreleased_headings(self) -> None: - with pytest.raises(ValueError, match="Duplicate Unreleased"): - parse_changelog(_PREAMBLE + _UNRELEASED + _UNRELEASED + _V072) - - def test_rejects_duplicate_release_headings(self) -> None: - with pytest.raises(ValueError, match="Duplicate changelog version"): - parse_changelog(_PREAMBLE + _V072 + _V072) - - -class TestGroupByMinor: - def test_groups_correctly(self) -> None: - _, _, blocks = parse_changelog(_full_changelog()) - groups = group_by_minor(blocks) - assert list(groups.keys()) == ["0.7", "0.6", "0.2"] - assert len(groups["0.7"]) == 2 - assert len(groups["0.6"]) == 2 - assert len(groups["0.2"]) == 1 - - -class TestExtractLinkDefs: - def test_extracts_trailing_defs(self) -> None: - text = _full_changelog_with_links() - cleaned, link_defs = _extract_link_defs(text) - assert "unreleased" in link_defs - assert "0.7.2" in link_defs - assert "0.2.0" in link_defs - assert len(link_defs) == 6 - # Cleaned text should not contain any link defs. - assert "[unreleased]:" not in cleaned - assert "[0.7.2]:" not in cleaned - - def test_no_link_defs(self) -> None: - cleaned, link_defs = _extract_link_defs(_full_changelog()) - assert link_defs == {} - assert cleaned == _full_changelog() - - def test_preserves_content_before_defs(self) -> None: - text = _full_changelog_with_links() - cleaned, _ = _extract_link_defs(text) - # All version headings should still be present. - assert "## [0.7.2]" in cleaned - assert "## [0.2.0]" in cleaned - assert "## [Unreleased]" in cleaned - - -class TestWriteArchive: - def test_writes_archive_file(self, tmp_path: Path) -> None: - _, _, blocks = parse_changelog(_full_changelog()) - groups = group_by_minor(blocks) - path = write_archive(tmp_path, "0.6", groups["0.6"]) - assert path.name == "0.6.md" - content = path.read_text(encoding="utf-8") - assert content.startswith("# Changelog - 0.6.x\n") - assert "## [0.6.2]" in content - assert "## [0.6.1]" in content - assert content.endswith("\n") - - def test_creates_directory(self, tmp_path: Path) -> None: - nested = tmp_path / "a" / "b" - write_archive(nested, "0.2", [("0.2.0", _V020)]) - assert (nested / "0.2.md").is_file() - - def test_includes_relevant_link_defs(self, tmp_path: Path) -> None: - _, link_defs = _extract_link_defs(_full_changelog_with_links()) - _, _, blocks = parse_changelog(_full_changelog()) - groups = group_by_minor(blocks) - path = write_archive(tmp_path, "0.6", groups["0.6"], link_defs) - content = path.read_text(encoding="utf-8") - # Should include only 0.6.x link defs. - assert "[0.6.2]:" in content - assert "[0.6.1]:" in content - # Should NOT include defs for other versions. - assert "[0.7.2]:" not in content - assert "[unreleased]:" not in content - assert "[0.2.0]:" not in content - - def test_postprocesses_archived_blocks(self, tmp_path: Path) -> None: - block = ( - "## [0.5.0] - 2025-01-01\n\n" - "### Fixed\n\n" - "- Fix remove_vertex topology consistency [#124](https://github.com/acgetchell/delaunay/pull/124)\n" - " [`da473c8`](https://github.com/acgetchell/delaunay/commit/da473c8deadbeef)\n\n" - " This commit addresses three critical issues:\n\n" - " 1. **Fix remove_vertex to maintain topology consistency**\n\n" - " - Added logic to clear dangling neighbor references\n" - ) - path = write_archive(tmp_path, "0.5", [("0.5.0", block)]) - content = path.read_text(encoding="utf-8") - assert "\n - Added logic to clear dangling neighbor references\n" in content - assert "\n - Added logic to clear dangling neighbor references\n" not in content - - -class TestBuildRoot: - def test_includes_active_and_archives(self) -> None: - preamble, unreleased, blocks = parse_changelog(_full_changelog()) - groups = group_by_minor(blocks) - root = build_root( - preamble, - unreleased, - groups["0.7"], - sorted(["0.6", "0.2"], reverse=True), - "docs/archive/changelog", - ) - assert "## [Unreleased]" in root - assert "## [0.7.2]" in root - assert "## [0.7.1]" in root - assert "## [0.6.2]" not in root - assert "## Archives" in root - assert "[0.6.x](docs/archive/changelog/0.6.md)" in root - assert "[0.2.x](docs/archive/changelog/0.2.md)" in root - - def test_no_archives_when_empty(self) -> None: - root = build_root("# H\n", "", [("1.0.0", _V072)], [], "archive") - assert "## Archives" not in root - - def test_no_link_defs_by_default(self) -> None: - """build_root does not emit link defs (handled by orchestrator).""" - preamble, unreleased, blocks = parse_changelog(_full_changelog()) - groups = group_by_minor(blocks) - root = build_root( - preamble, - unreleased, - groups["0.7"], - ["0.6", "0.2"], - "docs/archive/changelog", - ) - assert "[unreleased]:" not in root - assert "[0.7.2]:" not in root - - -# --------------------------------------------------------------------------- -# Integration / workflow tests -# --------------------------------------------------------------------------- - - -class TestArchiveChangelog: - def test_out_of_order_releases_preserve_root_and_archives(self, tmp_path: Path) -> None: - changelog = tmp_path / "CHANGELOG.md" - original = _PREAMBLE + _V071 + _V072 + _V062 - changelog.write_text(original, encoding="utf-8") - archive_dir = tmp_path / "docs" / "archive" / "changelog" - archive_dir.mkdir(parents=True) - existing_archive = archive_dir / "0.6.md" - existing = b"# Existing archive\r\n" - existing_archive.write_bytes(existing) - - with pytest.raises(ValueError, match="strictly descending semantic-version order"): - archive_changelog(changelog, archive_dir) - - assert changelog.read_text(encoding="utf-8") == original - assert existing_archive.read_bytes() == existing - assert sorted(path.name for path in archive_dir.iterdir()) == ["0.6.md"] - - def test_cli_reports_order_error_without_traceback( - self, - tmp_path: Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - changelog = tmp_path / "CHANGELOG.md" - original = _PREAMBLE + _V071 + _V072 + _V062 - changelog.write_text(original, encoding="utf-8") - archive_dir = tmp_path / "archive" - - status = archive_changelog_module.main([str(changelog), "--archive-dir", str(archive_dir)]) - - captured = capsys.readouterr() - assert status == 1 - assert "strictly descending semantic-version order" in captured.err - assert "Traceback" not in captured.err - assert changelog.read_text(encoding="utf-8") == original - assert not archive_dir.exists() - - def test_unknown_heading_preserves_root_and_archives(self, tmp_path: Path) -> None: - """An unknown version-like heading fails before any output is rewritten.""" - changelog = tmp_path / "CHANGELOG.md" - original = _PREAMBLE + _V072 + "## [CustomLabel]\n\n- Preserve me\n\n" + _V062 - changelog.write_text(original, encoding="utf-8") - archive_dir = tmp_path / "docs" / "archive" / "changelog" - archive_dir.mkdir(parents=True) - existing_archive = archive_dir / "0.5.md" - existing = "# Changelog - 0.5.x\n\nHistorical content\n" - existing_archive.write_text(existing, encoding="utf-8") - - with pytest.raises(ValueError, match="CustomLabel"): - archive_changelog(changelog, archive_dir) - - assert changelog.read_text(encoding="utf-8") == original - assert existing_archive.read_text(encoding="utf-8") == existing - assert sorted(path.name for path in archive_dir.iterdir()) == ["0.5.md"] - - def test_splits_and_archives(self, tmp_path: Path) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_full_changelog(), encoding="utf-8") - archive_dir = tmp_path / "docs" / "archive" / "changelog" - - archive_changelog(changelog, archive_dir) - - # Root should only contain 0.7.x + Archives. - root = changelog.read_text(encoding="utf-8") - assert "## [0.7.2]" in root - assert "## [0.7.1]" in root - assert "## [0.6.2]" not in root - assert "## Archives" in root - - # Archive files should exist. - assert (archive_dir / "0.6.md").is_file() - assert (archive_dir / "0.2.md").is_file() - - # Archive content should be verbatim. - a06 = (archive_dir / "0.6.md").read_text(encoding="utf-8") - assert "## [0.6.2]" in a06 - assert "## [0.6.1]" in a06 - - def test_multi_file_publication_rolls_back_on_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - changelog = tmp_path / "CHANGELOG.md" - original_root = _full_changelog() - changelog.write_text(original_root, encoding="utf-8") - archive_dir = tmp_path / "docs" / "archive" / "changelog" - archive_dir.mkdir(parents=True) - existing_archive = archive_dir / "0.6.md" - original_archive = b"# Existing 0.6 archive\r\n" - existing_archive.write_bytes(original_archive) - - def fail_root_publication(source: Path, destination: Path) -> None: - if destination == changelog: - msg = "simulated root publication failure" - raise OSError(msg) - source.replace(destination) - - monkeypatch.setattr("archive_changelog._replace_path", fail_root_publication) - - with pytest.raises(OSError, match="simulated root publication failure"): - archive_changelog(changelog, archive_dir) - - assert changelog.read_text(encoding="utf-8") == original_root - assert existing_archive.read_bytes() == original_archive - assert not (archive_dir / "0.2.md").exists() - - def test_stage_text_removes_temporary_file_when_fsync_fails( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - target = tmp_path / "CHANGELOG.md" - - def fail_fsync(_descriptor: int) -> None: - msg = "simulated fsync failure" - raise OSError(msg) - - monkeypatch.setattr(archive_changelog_module.os, "fsync", fail_fsync) - - with pytest.raises(OSError, match="simulated fsync failure"): - archive_changelog_module._stage_text(target, "payload\n") - - assert not list(tmp_path.glob(".CHANGELOG.md.*.tmp")) - - def test_stage_bytes_removes_temporary_file_when_fsync_fails( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - target = tmp_path / "0.4.md" - - def fail_fsync(_descriptor: int) -> None: - msg = "simulated fsync failure" - raise OSError(msg) - - monkeypatch.setattr(archive_changelog_module.os, "fsync", fail_fsync) - - with pytest.raises(OSError, match="simulated fsync failure"): - archive_changelog_module._stage_bytes(target, b"payload\n") - - assert not list(tmp_path.glob(".0.4.md.*.tmp")) - - def test_partial_staging_failure_removes_prior_temporary_files( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - first = tmp_path / "first.md" - second = tmp_path / "second.md" - real_stage_text = archive_changelog_module._stage_text - - def fail_second_stage(path: Path, text: str) -> Path: - if path == second: - msg = "simulated second staging failure" - raise OSError(msg) - return real_stage_text(path, text) - - monkeypatch.setattr(archive_changelog_module, "_stage_text", fail_second_stage) - - with pytest.raises(OSError, match="simulated second staging failure"): - archive_changelog_module._publish_texts( - { - first: "first\n", - second: "second\n", - } - ) - - assert not first.exists() - assert not second.exists() - assert not list(tmp_path.glob(".*.tmp")) - - def test_idempotent(self, tmp_path: Path) -> None: - """Running archive twice produces the same output.""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_full_changelog(), encoding="utf-8") - archive_dir = tmp_path / "docs" / "archive" / "changelog" - - archive_changelog(changelog, archive_dir) - first_root = changelog.read_text(encoding="utf-8") - first_a06 = (archive_dir / "0.6.md").read_text(encoding="utf-8") - - archive_changelog(changelog, archive_dir) - second_root = changelog.read_text(encoding="utf-8") - second_a06 = (archive_dir / "0.6.md").read_text(encoding="utf-8") - - assert first_root == second_root - assert first_a06 == second_a06 - - def test_single_minor_no_op(self, tmp_path: Path) -> None: - """When only one minor series exists, nothing is archived.""" - changelog = tmp_path / "CHANGELOG.md" - text = _PREAMBLE + _UNRELEASED + _V072 + _V071 - changelog.write_text(text, encoding="utf-8") - - archive_changelog(changelog, tmp_path / "archive") - # File should be unchanged. - assert changelog.read_text(encoding="utf-8") == text - assert not (tmp_path / "archive").exists() - - def test_no_versions_no_op(self, tmp_path: Path) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text("# Changelog\n\nNo versions yet.\n", encoding="utf-8") - archive_changelog(changelog, tmp_path / "archive") - assert changelog.read_text(encoding="utf-8") == "# Changelog\n\nNo versions yet.\n" - - def test_existing_archives_are_postprocessed(self, tmp_path: Path) -> None: - """Older archive files are normalized even when they are not regenerated.""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_PREAMBLE + _UNRELEASED + _V072 + _V071, encoding="utf-8") - archive_dir = tmp_path / "docs" / "archive" / "changelog" - archive_dir.mkdir(parents=True) - archive = archive_dir / "0.5.md" - archive.write_text( - "# Changelog - 0.5.x\n\n" - "## [0.5.3] - 2025-10-31\n\n" - "### Fixed\n\n" - "- Handle degenerate configurations [#116](https://github.com/acgetchell/causal-triangulations/pull/116)\n" - " [`a6ec3fa`](https://github.com/acgetchell/causal-triangulations/commit/a6ec3fadeadbeef)\n\n" - "## Duplicate Vertex Handling\n\n" - "- Add duplicate coordinate detection\n", - encoding="utf-8", - ) - - archive_changelog(changelog, archive_dir) - - content = archive.read_text(encoding="utf-8") - assert "\n## Duplicate Vertex Handling" not in content - assert "#### Duplicate Vertex Handling" in content - - def test_distributes_link_defs(self, tmp_path: Path) -> None: - """Reference-style link definitions are distributed to the correct files.""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_full_changelog_with_links(), encoding="utf-8") - archive_dir = tmp_path / "docs" / "archive" / "changelog" - - archive_changelog(changelog, archive_dir) - - root = changelog.read_text(encoding="utf-8") - # Root should have active + unreleased link defs. - assert "[unreleased]:" in root - assert "[0.7.2]:" in root - assert "[0.7.1]:" in root - # Root should NOT have archived version defs. - assert "[0.6.2]:" not in root - assert "[0.2.0]:" not in root - - # Archive files should only contain their own version defs. - a06 = (archive_dir / "0.6.md").read_text(encoding="utf-8") - assert "[0.6.2]:" in a06 - assert "[0.6.1]:" in a06 - assert "[0.7.2]:" not in a06 - assert "[unreleased]:" not in a06 - - a02 = (archive_dir / "0.2.md").read_text(encoding="utf-8") - assert "[0.2.0]:" in a02 - assert "[0.7.2]:" not in a02 - - def test_idempotent_with_link_defs(self, tmp_path: Path) -> None: - """Idempotency holds when link definitions are present.""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_full_changelog_with_links(), encoding="utf-8") - archive_dir = tmp_path / "docs" / "archive" / "changelog" - - archive_changelog(changelog, archive_dir) - first_root = changelog.read_text(encoding="utf-8") - first_a06 = (archive_dir / "0.6.md").read_text(encoding="utf-8") - - archive_changelog(changelog, archive_dir) - assert changelog.read_text(encoding="utf-8") == first_root - assert (archive_dir / "0.6.md").read_text(encoding="utf-8") == first_a06 - - def test_archive_dir_outside_changelog_tree_uses_relative_link( - self, - tmp_path: Path, - caplog: pytest.LogCaptureFixture, - ) -> None: - changelog_dir = tmp_path / "repo" - changelog_dir.mkdir() - changelog = changelog_dir / "CHANGELOG.md" - changelog.write_text(_full_changelog(), encoding="utf-8") - archive_dir = tmp_path / "outside" / "archive" - - with caplog.at_level(logging.WARNING, logger="archive_changelog"): - archive_changelog(changelog, archive_dir) - - root = changelog.read_text(encoding="utf-8") - assert "- [0.6.x](../outside/archive/0.6.md)" in root - assert str(archive_dir) in caplog.text - assert str(changelog_dir) in caplog.text - - def test_archive_dir_relpath_value_error_preserves_changelog( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Cross-volume paths fail without publishing absolute archive links.""" - changelog_dir = tmp_path / "repo" - changelog_dir.mkdir() - changelog = changelog_dir / "CHANGELOG.md" - original = _full_changelog() - changelog.write_text(original, encoding="utf-8") - archive_dir = tmp_path / "outside" / "archive" - archive_dir.mkdir(parents=True) - existing_archive = archive_dir / "0.5.md" - existing = "# Changelog - 0.5.x\n\nHistorical content\n" - existing_archive.write_text(existing, encoding="utf-8") - - def raise_cross_drive_value_error(_path: Path, _start: Path) -> str: - msg = "path is on mount 'D:', start on mount 'C:'" - raise ValueError(msg) - - monkeypatch.setattr("archive_changelog.os.path.relpath", raise_cross_drive_value_error) - - with pytest.raises(ValueError, match="different filesystem roots") as exc_info: - archive_changelog(changelog, archive_dir) - - root = changelog.read_text(encoding="utf-8") - assert root == original - assert str(archive_dir) not in root - assert archive_dir.as_posix() not in root - assert str(archive_dir) not in str(exc_info.value) - assert archive_dir.as_posix() not in str(exc_info.value) - assert isinstance(exc_info.value.__cause__, ValueError) - assert existing_archive.read_text(encoding="utf-8") == existing - assert sorted(path.name for path in archive_dir.iterdir()) == ["0.5.md"] - - -# --------------------------------------------------------------------------- -# tag_release archive fallback -# --------------------------------------------------------------------------- - - -class TestFormatLinkDefs: - def test_semver_ordering_with_double_digit_minor(self) -> None: - """Versions like 0.10.x sort after 0.9.x, not before.""" - link_defs = { - "0.10.0": "[0.10.0]: https://example.com/compare/v0.9.0..v0.10.0", - "0.9.0": "[0.9.0]: https://example.com/compare/v0.8.0..v0.9.0", - "0.7.10": "[0.7.10]: https://example.com/compare/v0.7.9..v0.7.10", - "0.7.2": "[0.7.2]: https://example.com/compare/v0.7.1..v0.7.2", - } - labels = {"0.10.0", "0.9.0", "0.7.10", "0.7.2"} - result = _format_link_defs(link_defs, labels) - lines = result.split("\n") - # Newest (0.10.0) first, then 0.9.0, 0.7.10, 0.7.2. - assert lines[0].startswith("[0.10.0]:") - assert lines[1].startswith("[0.9.0]:") - assert lines[2].startswith("[0.7.10]:") - assert lines[3].startswith("[0.7.2]:") - - def test_unreleased_sorts_first_in_reverse(self) -> None: - link_defs = { - "unreleased": "[unreleased]: https://example.com/compare/v0.7.2..HEAD", - "0.7.2": "[0.7.2]: https://example.com/compare/v0.7.1..v0.7.2", - } - result = _format_link_defs(link_defs, {"unreleased", "0.7.2"}) - lines = result.split("\n") - assert lines[0].startswith("[unreleased]:") - assert lines[1].startswith("[0.7.2]:") - - -class TestTagReleaseArchiveFallback: - def test_extract_from_archive(self, tmp_path: Path) -> None: - """extract_changelog_section falls back to archive when version not in root.""" - # Write a root changelog without 0.6.x. - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_PREAMBLE + _V072, encoding="utf-8") - - # Write an archive with 0.6.x. - archive_dir = tmp_path / "docs" / "archive" / "changelog" - archive_dir.mkdir(parents=True) - (archive_dir / "0.6.md").write_text( - "# Changelog - 0.6.x\n\n" + _V062 + _V061, - encoding="utf-8", - ) - - body, source = extract_changelog_section(changelog, "0.6.2") - assert "Bump dep in 0.6.2" in body - assert source == archive_dir / "0.6.md" - - def test_extract_from_root_returns_root_source(self, tmp_path: Path) -> None: - """extract_changelog_section returns the root changelog as source when found there.""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_PREAMBLE + _V072, encoding="utf-8") - - body, source = extract_changelog_section(changelog, "0.7.2") - assert "Bug fix in 0.7.2" in body - assert source == changelog - - def test_anchor_from_archive(self, tmp_path: Path) -> None: - """_github_anchor falls back to archive for archived versions.""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_PREAMBLE + _V072, encoding="utf-8") - - archive_dir = tmp_path / "docs" / "archive" / "changelog" - archive_dir.mkdir(parents=True) - (archive_dir / "0.6.md").write_text( - "# Changelog - 0.6.x\n\n" + _V062, - encoding="utf-8", - ) - - anchor = _github_anchor(changelog, "0.6.2") - assert "062" in anchor diff --git a/scripts/tests/test_changelog_integration.py b/scripts/tests/test_changelog_integration.py new file mode 100644 index 0000000..bf292fe --- /dev/null +++ b/scripts/tests/test_changelog_integration.py @@ -0,0 +1,379 @@ +"""Consumer integration with the pinned, published changelog CLI.""" + +import json +import os +import re +import shutil +import sys +import tomllib +from datetime import UTC, datetime, timedelta +from importlib.metadata import version +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] + + +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"): + 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) + run_git_command(["config", "user.name", "Changelog fixture"], cwd=root) + run_git_command(["config", "user.email", "fixture@example.invalid"], cwd=root) + run_git_command(["config", "commit.gpgsign", "false"], cwd=root) + run_git_command(["config", "tag.gpgsign", "false"], cwd=root) + return root + + +@pytest.fixture +def consumer(tmp_path: Path) -> Path: + """Create one disposable consumer independent of live release and signing state.""" + return create_consumer(tmp_path, REPO_ROOT) + + +def cli(root: Path, *args: str) -> int: + """Call only the documented installed-package entry point.""" + return main(["--root", str(root), "changelog", *args]) + + +def markdown_bytes(root: Path) -> dict[Path, bytes]: + """Capture changelog outputs for read-only and failed-publication assertions.""" + paths = [root / "CHANGELOG.md", *(root / "docs/archives/changelog").glob("*.md")] + return {path.relative_to(root): path.read_bytes() for path in paths if path.is_file()} + + +def commit(root: Path, message: str) -> None: + """Append one real commit to the fixture history.""" + run_git_command(["commit", "--quiet", "--allow-empty", "-m", message], cwd=root) + + +def test_published_package_is_exactly_pinned_and_included_in_dev() -> None: + """Normal sync installs a PyPI release, with no sibling or editable override.""" + manifest = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + groups = manifest["dependency-groups"] + assert groups["tooling"] == ["research-repo-tools==0.1.7"] + assert {"include-group": "tooling"} in groups["dev"] + assert version("research-repo-tools") == "0.1.7" + lock = tomllib.loads((REPO_ROOT / "uv.lock").read_text(encoding="utf-8")) + package = next(package for package in lock["package"] if package["name"] == "research-repo-tools") + assert package["version"] == "0.1.7" + assert package["source"] == {"registry": "https://pypi.org/simple"} + + +def test_archive_rotation_preserves_notes_references_and_relative_links(consumer: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Root and archived notes remain discoverable after a minor-series rotation.""" + changelog = consumer / "CHANGELOG.md" + changelog.write_text( + "# Changelog\n\n## [Unreleased]\n\n- Pending.\n\n" + "## [0.5.0] - 2026-09-30\n\n- New minor.\n\n" + "## [0.4.6] - 2026-09-08\n\n- Keep `Vector<D>` and [guide][guide].\n\n" + "## [0.4.5] - 2026-08-21\n\n- Prior patch.\n\n" + "[guide]: README.md\n" + "[0.5.0]: https://github.com/acgetchell/la-stack/compare/v0.4.6...v0.5.0\n" + "[0.4.6]: https://github.com/acgetchell/la-stack/compare/v0.4.5...v0.4.6\n", + encoding="utf-8", + ) + assert cli(consumer, "archive") == 0 + root = changelog.read_text(encoding="utf-8") + archive = consumer / "docs/archives/changelog/0.4.md" + archived = archive.read_text(encoding="utf-8") + assert "## [Unreleased]" in root + assert "## [0.5.0] - 2026-09-30" in root + assert "## [0.4.6]" not in root + assert "[0.4.x](docs/archives/changelog/0.4.md)" in root + assert "## [0.4.6] - 2026-09-08" in archived + assert "## [0.4.5] - 2026-08-21" in archived + assert "`Vector<D>`" in archived + assert "[guide]: ../../../README.md" in archived + before = markdown_bytes(consumer) + assert cli(consumer, "archive") == 0 + assert cli(consumer, "check") == 0 + assert markdown_bytes(consumer) == before + capsys.readouterr() + assert cli(consumer, "notes", "v0.4.6") == 0 + notes = capsys.readouterr().out + assert "Keep `Vector<D>`" in notes + assert "[guide]: ../../../README.md" in notes + assert "Prior patch" not in notes + assert "[0.5.0]:" not in notes + assert cli(consumer, "notes", "v0.5.0") == 0 + assert "New minor" in capsys.readouterr().out + assert markdown_bytes(consumer) == before + + +def test_generation_uses_consumer_policy_dates_and_transactional_preview(consumer: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Real Git/git-cliff generation retains authored dependency links and explicit dates.""" + commit(consumer, "feat: add exact arithmetic") + run_git_command(["tag", "v0.4.5"], cwd=consumer) + commit( + consumer, + "chore(deps-dev): bump ruff from 0.16.1 to 0.16.2\n\nRead [release notes](https://github.com/astral-sh/ruff/compare/0.16.1...0.16.2).", + ) + commit(consumer, "fix!: preserve matrix invariants (#42)\n\nBREAKING CHANGE: preserve exact errors and migration instructions.") + (consumer / "CHANGELOG.md").write_text("# Changelog\n\n## [0.4.5] - 2000-01-02\n\n- Authored date.\n", encoding="utf-8") + metadata_before = {name: (consumer / name).read_bytes() for name in ("Cargo.toml", "pyproject.toml")} + before = markdown_bytes(consumer) + assert cli(consumer, "generate", "--tag", "v0.5.0", "--date", "2026-09-30", "--dry-run") == 0 + preview = capsys.readouterr().out + assert "## [0.5.0] - 2026-09-30" in preview + assert "https://github.com/astral-sh/ruff/compare/0.16.1...0.16.2" in preview + assert "https://github.com/acgetchell/la-stack/pull/42" in preview + assert "preserve exact errors and migration instructions" in preview + assert markdown_bytes(consumer) == before + assert not (consumer / "docs/archives/changelog").exists() + assert cli(consumer, "generate", "--tag", "v0.5.0", "--date", "2026-09-30") == 0 + assert (consumer / "CHANGELOG.md").read_text(encoding="utf-8") == preview + archive = consumer / "docs/archives/changelog/0.4.md" + assert "## [0.4.5] - 2000-01-02" in archive.read_text(encoding="utf-8") + assert {name: (consumer / name).read_bytes() for name in metadata_before} == metadata_before + generated = markdown_bytes(consumer) + assert cli(consumer, "generate", "--tag", "v0.5.0", "--date", "2026-09-30") == 0 + assert markdown_bytes(consumer) == generated + + +@pytest.mark.parametrize( + ("args", "diagnostic"), + [ + (("generate", "--tag", "v0.5.0"), "both --tag and --date"), + (("generate", "--date", "2026-09-30"), "both --tag and --date"), + (("generate", "--tag", "v0.5.0", "--date", "2026-02-30"), "day"), + (("generate", "--tag", "v01.5.0", "--date", "2026-09-30"), "SemVer format"), + (("notes", "0.4.6"), "SemVer format"), + (("notes", "v9.9.9"), "not found"), + ], +) +def test_cli_errors_are_actionable_and_preserve_outputs(consumer: Path, capsys: pytest.CaptureFixture[str], args: tuple[str, ...], diagnostic: str) -> None: + (consumer / "CHANGELOG.md").write_text("# Changelog\n\n## [0.4.6] - 2026-09-08\n\n- Retained.\n", encoding="utf-8") + before = markdown_bytes(consumer) + assert cli(consumer, *args) == 1 + error = capsys.readouterr().err + assert "research-repo-tools:" in error + assert diagnostic.casefold() in error.casefold() + assert "Traceback" not in error + assert markdown_bytes(consumer) == before + + +@pytest.mark.parametrize( + ("headings", "diagnostic"), + [ + ("## [0.4.5]\n\n- Older.\n\n## [0.4.6]\n\n- Newer.\n", "out of order"), + ("## [0.4.6]\n\n- One.\n\n## [0.4.6]\n\n- Duplicate.\n", "duplicate"), + ("## [0.4.6] - 2026-02-30\n\n- Invalid date.\n", "date"), + ], +) +def test_whole_history_errors_do_not_rotate_files(consumer: Path, capsys: pytest.CaptureFixture[str], headings: str, diagnostic: str) -> None: + (consumer / "CHANGELOG.md").write_text("# Changelog\n\n" + headings, encoding="utf-8") + before = markdown_bytes(consumer) + for command in ("check", "archive"): + assert cli(consumer, command) == 1 + assert diagnostic in capsys.readouterr().err.casefold() + assert markdown_bytes(consumer) == before + + +def test_archive_conflicts_preserve_retained_history(consumer: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Changed notes for an already archived release fail before any replacement.""" + (consumer / "CHANGELOG.md").write_text( + "# Changelog\n\n## [0.5.0] - 2026-09-30\n\n- Current.\n\n## [0.4.6] - 2026-09-08\n\n- Conflicting notes.\n", + encoding="utf-8", + ) + archive = consumer / "docs/archives/changelog/0.4.md" + archive.parent.mkdir(parents=True) + archive.write_text("# Changelog - 0.4.x\n\n## [0.4.6] - 2026-09-08\n\n- Authored retained notes.\n", encoding="utf-8") + before = markdown_bytes(consumer) + assert cli(consumer, "archive") == 1 + assert "conflicting retained release" in capsys.readouterr().err.casefold() + assert markdown_bytes(consumer) == before + + +def test_tag_previews_and_force_preserve_existing_ref_on_error(consumer: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Exercise tag and force through the public CLI in a disposable repository only.""" + commit(consumer, "feat: initial fixture") + release_date = datetime.now(UTC).date().isoformat() + (consumer / "CHANGELOG.md").write_text(f"# Changelog\n\n## [0.4.6] - {release_date}\n\n- Exact release notes.\n", encoding="utf-8") + before = markdown_bytes(consumer) + assert cli(consumer, "tag", "v0.4.6", "--dry-run") == 0 + assert "Exact release notes" in capsys.readouterr().out + assert run_git_command(["tag", "--list"], cwd=consumer).stdout == "" + assert cli(consumer, "tag", "v0.4.6") == 0 + ref = run_git_command(["rev-parse", "refs/tags/v0.4.6"], cwd=consumer).stdout + assert run_git_command(["cat-file", "-t", "refs/tags/v0.4.6"], cwd=consumer).stdout.strip() == "tag" + assert cli(consumer, "tag", "v0.4.6") == 1 + assert "already exists" in capsys.readouterr().err + assert cli(consumer, "tag", "v0.5.0", "--force") == 1 + assert "does not match package version" in capsys.readouterr().err + (consumer / "CHANGELOG.md").write_text("# Changelog\n\n## [0.4.6] - 2026-02-30\n\n- Invalid.\n", encoding="utf-8") + assert cli(consumer, "tag", "v0.4.6", "--force") == 1 + assert run_git_command(["rev-parse", "refs/tags/v0.4.6"], cwd=consumer).stdout == ref + (consumer / "CHANGELOG.md").write_bytes(before[Path("CHANGELOG.md")]) + assert cli(consumer, "tag", "v0.4.6", "--force") == 0 + assert markdown_bytes(consumer) == before + + +def test_consumer_recipes_forward_cli_arguments_and_keep_metadata_separate() -> None: + """Check the actual merged recipes and alias, including shell-safe argument forwarding.""" + recipes = json.loads(run_safe_command("just", ["--dump", "--dump-format", "json"], cwd=REPO_ROOT).stdout) + assert recipes["aliases"]["changelog-unreleased"]["target"] == "changelog-release" + commands = { + ("changelog",): "changelog generate", + ("changelog-preview", "--tag", "v0.5.0", "--date", "2026-09-30"): 'generate --dry-run "$@"', + ("changelog-release", "v0.5.0", "2026-09-30"): "generate --tag 'v0.5.0' --date '2026-09-30'", + ("changelog-unreleased", "v0.5.0", "2026-09-30"): "generate --tag 'v0.5.0' --date '2026-09-30'", + ("changelog-archive",): "changelog archive", + ("changelog-check",): "changelog check", + ("release-notes", "v0.4.6"): "changelog notes 'v0.4.6'", + ("tag", "v0.4.6"): "changelog tag 'v0.4.6'", + ("tag-force", "v0.4.6"): "changelog tag 'v0.4.6' --force", + } + 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 "update-release-version" not in result.stderr + + +def test_preview_recipe_executes_explicit_tag_and_date_without_publishing(consumer: Path) -> None: + """Run the imported Just adapter and installed CLI, skipping installation only.""" + commit(consumer, "feat: initial fixture") + run_git_command(["tag", "v0.4.5"], cwd=consumer) + commit(consumer, "fix: preview target date") + (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", + encoding="utf-8", + ) + before = markdown_bytes(consumer) + metadata = {name: (consumer / name).read_bytes() for name in ("Cargo.toml", "pyproject.toml")} + result = run_safe_command( + "just", + ["--justfile", str(wrapper), "--working-directory", str(consumer), "changelog-preview", "--tag", "v0.5.0", "--date", "2026-09-30"], + cwd=consumer, + env=os.environ | {"UV_NO_SYNC": "1", "UV_PROJECT_ENVIRONMENT": sys.prefix}, + ) + assert "## [0.5.0] - 2026-09-30" in result.stdout + assert markdown_bytes(consumer) == before + assert {name: (consumer / name).read_bytes() for name in metadata} == metadata + assert not (consumer / "docs/archives/changelog").exists() + + +def test_final_repository_links_and_archived_notes_remain_valid(capsys: pytest.CaptureFixture[str]) -> None: + """Check consumer history, including references in every completed series.""" + assert cli(REPO_ROOT, "check") == 0 + root = (REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + for relative in re.findall(r"\]\((docs/archives/changelog/[^)]+)\)", root): + assert (REPO_ROOT / relative).is_file() + for tag in ("v0.1.0", "v0.2.0", "v0.3.0", "v0.4.6"): + assert cli(REPO_ROOT, "notes", tag) == 0 + assert capsys.readouterr().out.strip() + policy = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))["tool"]["rumdl"] + assert "MD057" not in policy["disable"] + formatter = tomllib.loads((REPO_ROOT / "changelog-rumdl.toml").read_text(encoding="utf-8")) + assert formatter["extends"] == "pyproject.toml" + assert formatter["global"]["extend-disable"] == ["MD057"] + + +def test_tag_preview_checks_citation_date_and_handles_oversized_notes(consumer: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Tag annotations validate consumer release dates and retain a full-notes link.""" + commit(consumer, "feat: initial fixture") + released = datetime.now(UTC).date().isoformat() + (consumer / "CHANGELOG.md").write_text(f"# Changelog\n\n## [0.4.6] - {released}\n\n- {'Long notes. ' * 12000}\n", encoding="utf-8") + (consumer / "CITATION.cff").write_text("version: 0.4.6\ndate-released: 2000-01-01\n", encoding="utf-8") + assert cli(consumer, "tag", "v0.4.6", "--dry-run") == 1 + assert "citation.cff date and changelog release date differ" in capsys.readouterr().err.casefold() + (consumer / "CITATION.cff").write_text(f"version: 0.4.6\ndate-released: {released}\n", encoding="utf-8") + before = markdown_bytes(consumer) + assert cli(consumer, "tag", "v0.4.6", "--dry-run") == 0 + preview = capsys.readouterr().out + assert "https://github.com/acgetchell/la-stack/blob/v0.4.6/CHANGELOG.md#" in preview + assert len(preview.encode("utf-8")) < 125000 + assert markdown_bytes(consumer) == before + assert run_git_command(["tag", "--list"], cwd=consumer).stdout == "" + + +def test_tag_accepts_a_declared_release_date_before_the_tagging_day(consumer: Path) -> None: + """A release prepared before merge remains taggable with matching metadata.""" + commit(consumer, "feat: prior-day release fixture") + released = (datetime.now(UTC).date() - timedelta(days=1)).isoformat() + (consumer / "CHANGELOG.md").write_text(f"# Changelog\n\n## [0.4.6] - {released}\n\n- Prior-day release.\n", encoding="utf-8") + (consumer / "CITATION.cff").write_text(f"version: 0.4.6\ndate-released: {released}\n", encoding="utf-8") + before = markdown_bytes(consumer) + assert cli(consumer, "tag", "v0.4.6") == 0 + assert run_git_command(["cat-file", "-t", "refs/tags/v0.4.6"], cwd=consumer).stdout.strip() == "tag" + assert "Prior-day release" in run_git_command(["cat-file", "-p", "refs/tags/v0.4.6"], cwd=consumer).stdout + assert markdown_bytes(consumer) == before + + +def test_fixture_isolates_commits_and_tags_from_global_signing(consumer: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Synthetic Git operations need no key when ambient signing is enabled.""" + signing_config = consumer / "global-signing.gitconfig" + signing_config.write_text("[commit]\ngpgsign = true\n[tag]\ngpgsign = true\n[gpg]\nprogram = fixture-signing-must-not-run\n", encoding="utf-8") + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(signing_config)) + commit(consumer, "feat: signing isolation fixture") + released = datetime.now(UTC).date().isoformat() + (consumer / "CHANGELOG.md").write_text(f"# Changelog\n\n## [0.4.6] - {released}\n\n- Unsigned fixture tag.\n", encoding="utf-8") + assert cli(consumer, "tag", "v0.4.6") == 0 + assert run_git_command(["cat-file", "-t", "refs/tags/v0.4.6"], cwd=consumer).stdout.strip() == "tag" + + +def test_fixture_tags_remain_valid_after_the_source_release_version_advances(tmp_path: Path) -> None: + """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"): + shutil.copyfile(REPO_ROOT / name, source / name) + manifest = source / "pyproject.toml" + text = manifest.read_text(encoding="utf-8") + version_before = tomllib.loads(text)["project"]["version"] + manifest.write_text(text.replace(f'version = "{version_before}"', 'version = "0.4.7"', 1), encoding="utf-8") + (source / "Cargo.toml").write_text('[package]\nname = "la-stack"\nversion = "0.4.7"\n', encoding="utf-8") + root = tmp_path / "consumer" + root.mkdir() + create_consumer(root, source) + commit(root, "feat: next source release fixture") + released = datetime.now(UTC).date().isoformat() + (root / "CHANGELOG.md").write_text(f"# Changelog\n\n## [0.4.6] - {released}\n\n- Stable fixture identity.\n", encoding="utf-8") + (root / "CITATION.cff").write_text(f"version: 0.4.6\ndate-released: {released}\n", encoding="utf-8") + assert tomllib.loads((source / "Cargo.toml").read_text(encoding="utf-8"))["package"]["version"] == "0.4.7" + assert tomllib.loads(manifest.read_text(encoding="utf-8"))["project"]["version"] == "0.4.7" + assert cli(root, "tag", "v0.4.6") == 0 + assert run_git_command(["cat-file", "-t", "refs/tags/v0.4.6"], cwd=root).stdout.strip() == "tag" + + +def test_normalization_preserves_fenced_examples_and_prerelease_notes(consumer: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Rust code, Markdown links, and fenced release examples remain literal.""" + (consumer / "CHANGELOG.md").write_text( + "# Changelog\n\n## [0.4.6-rc.1+build.7] - 2026-09-30\n\n" + "- Keep `Vector<D>` and [PR](https://github.com/acgetchell/la-stack/pull/42).\n\n" + "```rust\nfn value<T>() -> Vector<T> { todo!() }\n```\n\n" + "~~~text\n## [99.9.9]\n~~~\n", + encoding="utf-8", + ) + assert cli(consumer, "normalize") == 0 + assert cli(consumer, "notes", "v0.4.6-rc.1+build.7") == 0 + notes = capsys.readouterr().out + assert "`Vector<D>`" in notes + assert "fn value<T>() -> Vector<T>" in notes + assert "## [99.9.9]" in notes + assert "https://github.com/acgetchell/la-stack/pull/42" in notes + assert cli(consumer, "check") == 0 + + +def test_formatter_failure_preserves_every_candidate(consumer: Path, capsys: pytest.CaptureFixture[str]) -> None: + """A consumer configuration error cannot publish a partial root or archive.""" + commit(consumer, "feat: initial fixture") + run_git_command(["tag", "v0.4.5"], cwd=consumer) + commit(consumer, "fix: add an unreleased change") + (consumer / "CHANGELOG.md").write_text("# Changelog\n\n## [0.4.5] - 2026-08-21\n\n- Retained.\n", encoding="utf-8") + manifest = consumer / "pyproject.toml" + manifest.write_text(manifest.read_text(encoding="utf-8").replace('formatter = "changelog-rumdl.toml"', 'formatter = "missing.toml"'), encoding="utf-8") + before = markdown_bytes(consumer) + assert cli(consumer, "generate", "--tag", "v0.5.0", "--date", "2026-09-30") == 1 + assert "formatter configuration not found" in capsys.readouterr().err.casefold() + assert markdown_bytes(consumer) == before + assert not (consumer / "docs/archives/changelog").exists() diff --git a/scripts/tests/test_check_docs_version_sync.py b/scripts/tests/test_check_docs_version_sync.py index 35fba90..45ef97a 100644 --- a/scripts/tests/test_check_docs_version_sync.py +++ b/scripts/tests/test_check_docs_version_sync.py @@ -193,9 +193,10 @@ def test_readme_tag_references_accept_semver_suffixes(tmp_path: Path, version: s assert [(reference.line, reference.version) for reference in references] == [(1, version)] -def test_find_version_mismatches_ignores_historical_docs_and_test_fixtures(tmp_path: Path) -> None: +@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" + archive = tmp_path / "docs" / archive_dir archive.mkdir(parents=True) fixtures = tmp_path / "tests" / "fixtures" fixtures.mkdir(parents=True) diff --git a/scripts/tests/test_postprocess_changelog.py b/scripts/tests/test_postprocess_changelog.py deleted file mode 100644 index 48f43e5..0000000 --- a/scripts/tests/test_postprocess_changelog.py +++ /dev/null @@ -1,1056 +0,0 @@ -"""Tests for postprocess_changelog.py — trailing blanks, reflow, code blocks, summaries.""" - -import os -from pathlib import Path -from typing import Never - -import pytest - -import postprocess_changelog -from postprocess_changelog import ( - _CodeFence, - _compact_entry, - _inject_summary_sections, - _is_duplicate_squash_heading, - _is_isolated_body_heading, - _max_pr_number, - _normalize_email_autolinks, - _normalize_entry_heading, - _normalize_indented_heading, - _normalize_list_continuation_indent, - _normalize_squash_heading, - _plain_summary, - _process_code_fence, - _reflow_line, - _squash_heading_parts, - _strip_dependabot_metadata, - normalize_entry_headings_text, - postprocess, - postprocess_text, -) -from subprocess_utils import run_safe_command - - -class TestStripTrailingBlanks: - def test_strips_trailing_blank_lines(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("# Changelog\n\n- Item\n\n\n\n", encoding="utf-8") - - postprocess(f) - - assert f.read_text(encoding="utf-8") == "# Changelog\n\n- Item\n" - - @pytest.mark.skipif(os.name == "nt", reason="POSIX mode preservation is not meaningful on Windows") - def test_atomic_write_preserves_file_mode(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("# Changelog\n\n- Item\n\n\n", encoding="utf-8") - f.chmod(0o640) - - postprocess(f) - - assert f.stat().st_mode & 0o777 == 0o640 - - @pytest.mark.parametrize("failure_point", ["stage", "fsync", "replace"]) - def test_atomic_write_failure_preserves_original( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - failure_point: str, - ) -> None: - f = tmp_path / "CHANGELOG.md" - original = b"# Changelog\n\n- Item\n\n\n" - f.write_bytes(original) - - def fail(*_args: object, **_kwargs: object) -> Never: - msg = f"simulated {failure_point} failure" - raise OSError(msg) - - if failure_point == "stage": - monkeypatch.setattr(postprocess_changelog.tempfile, "NamedTemporaryFile", fail) - elif failure_point == "fsync": - monkeypatch.setattr(postprocess_changelog.os, "fsync", fail) - else: - monkeypatch.setattr(postprocess_changelog.Path, "replace", fail) - - with pytest.raises(OSError, match=f"simulated {failure_point} failure"): - postprocess(f) - - assert f.read_bytes() == original - assert not list(tmp_path.glob(".CHANGELOG.md.*.tmp")) - - def test_preserves_single_trailing_newline(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("# Changelog\n\n- Item\n", encoding="utf-8") - - postprocess(f) - - assert f.read_text(encoding="utf-8") == "# Changelog\n\n- Item\n" - - def test_adds_trailing_newline_if_missing(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("# Changelog\n\n- Item", encoding="utf-8") - - postprocess(f) - - assert f.read_text(encoding="utf-8") == "# Changelog\n\n- Item\n" - - def test_preserves_internal_blank_lines(self, tmp_path: Path) -> None: - content = "# Changelog\n\n## [1.0.0]\n\n### Added\n\n- Item\n\n\n\n" - f = tmp_path / "CHANGELOG.md" - f.write_text(content, encoding="utf-8") - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert result == "# Changelog\n\n## [1.0.0]\n\n### Added\n\n- Item\n" - - def test_single_newline_file(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("\n", encoding="utf-8") - - postprocess(f) - - assert f.read_text(encoding="utf-8") == "\n" - - def test_empty_file(self, tmp_path: Path) -> None: - """ - Verifies that processing an empty changelog file results in a file containing exactly one newline. - - Creates an empty CHANGELOG.md at the provided temporary path, runs postprocess on it, and asserts the file's contents are "\n". - """ - f = tmp_path / "CHANGELOG.md" - f.write_text("", encoding="utf-8") - - postprocess(f) - - assert f.read_text(encoding="utf-8") == "\n" - - -class TestDependabotMetadata: - def test_strips_yaml_footer_from_commit_body(self) -> None: - content = ( - "- Update setuptools [`abcdef0`](https://example.com/commit/abcdef0)\n\n" - " Updates setuptools to 83.0.0\n\n" - "---\n\n" - " updated-dependencies:\n\n" - "- dependency-name: setuptools\n" - " dependency-version: 83.0.0\n" - " dependency-type: direct:development\n" - " ...\n" - "- Next entry\n" - ) - - assert _strip_dependabot_metadata(content) == ( - "- Update setuptools [`abcdef0`](https://example.com/commit/abcdef0)\n\n Updates setuptools to 83.0.0\n\n- Next entry\n" - ) - - def test_preserves_unrelated_thematic_break(self) -> None: - content = "- Entry\n\n---\n\nAdditional context\n" - - assert _strip_dependabot_metadata(content) == content - - def test_postprocess_text_strips_dependabot_footer(self) -> None: - content = "- Update setuptools\n\n---\n\nupdated-dependencies:\n- dependency-name: setuptools\n dependency-version: 83.0.0\n...\n\n- Next entry\n" - - result = postprocess_text(content) - - assert "updated-dependencies:" not in result - assert result == "- Update setuptools\n- Next entry\n" - - def test_postprocess_text_preserves_dependabot_markers_inside_yaml_fence(self) -> None: - fenced_example = "```yaml\n---\nupdated-dependencies:\n- dependency-name: setuptools\n...\n```" - - result = postprocess_text(f"Example metadata:\n\n{fenced_example}\n") - - assert fenced_example in result - - -class TestListContinuationIndent: - def test_normalizes_over_indented_top_level_continuation(self) -> None: - lines = [ - "- Replace BigRational-only gauss_solve", - "", - " Bareiss fraction-free forward elimination.", - ] - - result = _normalize_list_continuation_indent(lines[2], lines, 2) - - assert result == " Bareiss fraction-free forward elimination." - - def test_preserves_nested_list_continuation_indent(self) -> None: - lines = [ - "- Parent item", - "", - " - Nested item", - " continued nested prose", - ] - - result = _normalize_list_continuation_indent(lines[3], lines, 3) - - assert result == " continued nested prose" - - def test_full_postprocess_normalizes_git_cliff_continuation(self) -> None: - content = ( - "# Changelog\n\n" - "## [1.0.0] - 2026-01-01\n\n" - "### Performance\n\n" - "- Integer-only Bareiss determinant via BigInt\n" - " [`d422b25`](https://github.com/acgetchell/la-stack/commit/d422b251ca86a914522f80285964d4513bca1817)\n\n" - " det_exact: 16x faster\n" - ) - - result = postprocess_text(content) - - assert "\n det_exact: 16x faster\n" in result - assert "\n det_exact: 16x faster\n" not in result - - -class TestRumdlCompatibility: - def test_removes_blank_between_peer_list_items(self) -> None: - content = ( - "# Changelog\n\n" - "## [1.0.0] - 2026-01-01\n\n" - "### Added\n\n" - "- First item\n" - " [`1111111`](https://github.com/acgetchell/la-stack/commit/1111111111111111111111111111111111111111)\n\n" - "- Second item\n" - ) - - result = postprocess_text(content) - - assert "\n\n- Second item\n" not in result - assert "\n- Second item\n" in result - - def test_removes_blank_after_nested_body_before_top_level_peer(self) -> None: - content = "# Changelog\n\n## [1.0.0] - 2026-01-01\n\n### Added\n\n- First item\n\n - Body point\n - Body point\n\n- Second item\n" - - result = postprocess_text(content) - - assert "\n\n- Second item\n" not in result - assert "\n- Second item\n" in result - - def test_removes_blank_after_mixed_body_before_top_level_peer(self) -> None: - content = ( - "# Changelog\n\n" - "## [1.0.0] - 2026-01-01\n\n" - "### Changed\n\n" - "- First item\n\n" - " Body paragraph.\n\n" - " - Body point\n" - " - Body point\n\n" - " More body prose.\n\n" - "- Second item\n" - ) - - result = postprocess_text(content) - - assert "\n\n- Second item\n" not in result - assert "\n- Second item\n" in result - - def test_preserves_blank_before_list_item_body(self) -> None: - content = "# Changelog\n\n## [1.0.0] - 2026-01-01\n\n### Added\n\n- First item\n\n Body paragraph.\n" - - result = postprocess_text(content) - - assert "- First item\n\n Body paragraph.\n" in result - - def test_keeps_body_item_tight_after_plain_peer(self) -> None: - content = ( - "# Changelog\n\n" - "## [1.0.0] - 2026-01-01\n\n" - "### Added\n\n" - "- Simple item [`1111111`](https://github.com/acgetchell/la-stack/commit/1111111111111111111111111111111111111111)\n" - "- Item with body [`2222222`](https://github.com/acgetchell/la-stack/commit/2222222222222222222222222222222222222222)\n\n" - " Body paragraph.\n" - ) - - result = postprocess_text(content) - - assert "\n- Simple item" in result - assert "\n- Item with body" in result - assert "\n\n- Item with body" not in result - - def test_normalizes_git_cliff_escaped_email_autolink(self) -> None: - line = " Co-Authored-By: Oz <oz-agent@warp.dev>" - - result = _normalize_email_autolinks(line) - - assert result == " Co-Authored-By: Oz <oz-agent@warp.dev>" - - -class TestReflowLine: - """Unit tests for the _reflow_line helper.""" - - def test_short_line_unchanged(self) -> None: - line = "- Short line `abc1234`" - assert _reflow_line(line, max_width=160) == line - - def test_wraps_plain_text(self) -> None: - line = " " + "word " * 40 - result = _reflow_line(line.rstrip(), max_width=80) - for part in result.split("\n"): - assert len(part) <= 80 - - def test_preserves_markdown_link(self) -> None: - link = "[#235](https://github.com/acgetchell/delaunay/pull/235)" - line = f"- Description text here {link}" - result = _reflow_line(line, max_width=40) - # The link must appear intact in one of the output lines. - assert any(link in part for part in result.split("\n")) - - def test_preserves_code_span(self) -> None: - span = "`orientation_from_matrix()`" - line = f" Use {span} for exact sign classification on finite inputs and more text padding" - result = _reflow_line(line, max_width=60) - assert any(span in part for part in result.split("\n")) - - def test_list_item_continuation_indent(self) -> None: - line = "- " + "word " * 40 - result = _reflow_line(line.rstrip(), max_width=80) - parts = result.split("\n") - assert parts[0].startswith("- ") - for cont in parts[1:]: - assert cont.startswith(" ") - - def test_star_list_item(self) -> None: - line = "* " + "word " * 40 - result = _reflow_line(line.rstrip(), max_width=80) - parts = result.split("\n") - assert parts[0].startswith("* ") - for cont in parts[1:]: - assert cont.startswith(" ") - - def test_indented_body_text(self) -> None: - line = " " + "word " * 40 - result = _reflow_line(line.rstrip(), max_width=80) - parts = result.split("\n") - for part in parts: - assert part.startswith(" ") - - def test_single_long_token_kept(self) -> None: - url = "https://github.com/acgetchell/delaunay/commit/" + "a" * 40 - line = f"- See [{url}]({url})" - result = _reflow_line(line, max_width=80) - # Cannot break inside the link; line may exceed max_width. - assert url in result - - def test_commit_link_with_backticks(self) -> None: - link = "[`a62437f`](https://github.com/acgetchell/delaunay/commit/a62437f25c27259f145d3c193ce149ee14b421c7)" - pr1 = "[#235](https://github.com/acgetchell/delaunay/pull/235)" - pr2 = "[#236](https://github.com/acgetchell/delaunay/pull/236)" - line = f"- Use exact arithmetic for orientation predicates {pr1} {pr2} {link}" - result = _reflow_line(line, max_width=160) - parts = result.split("\n") - # Every continuation line should be indented. - for cont in parts[1:]: - assert cont.startswith(" ") - # All links must be intact. - assert link in result - - def test_preserves_link_with_balanced_destination_parentheses(self) -> None: - link = "[API](https://example.com/search(function(arg(nested))))" - line = f"- Read the detailed publication API notes before continuing with the release process {link}" - - result = _reflow_line(line, max_width=60) - - assert link in result - - def test_preserves_multi_backtick_code_span(self) -> None: - span = "``call(`inner`, value)``" - line = f"- Use {span} when documenting the generated command and all of its arguments" - - result = _reflow_line(line, max_width=45) - - assert span in result - - -# --------------------------------------------------------------------------- -# Summary-section helpers -# --------------------------------------------------------------------------- - -_OWNER_REPO = "acgetchell/delaunay" -_PR_URL = f"https://github.com/{_OWNER_REPO}/pull" -_COMMIT_URL = f"https://github.com/{_OWNER_REPO}/commit" - - -def _pr(n: int) -> str: - """ - Return a Markdown-formatted pull request link for a given pull request number. - - Parameters: - n (int): Pull request number. - - Returns: - str: Markdown link in the form "[#<n>](<PR_URL>/<n>)". - """ - return f"[#{n}]({_PR_URL}/{n})" - - -def _commit(short: str = "abc1234", full: str = "abc1234deadbeef0123456789") -> str: - """ - Format a markdown link that references a commit using a short hash as link text and the full hash in the URL. - - Parameters: - short (str): Short commit identifier used as the link text (rendered in backticks). - full (str): Full commit hash used to construct the target URL. - - Returns: - commit_link (str): Markdown link of the form [`<short>`](<commit_url>/<full>). - """ - return f"[`{short}`]({_COMMIT_URL}/{full})" - - -def _merged_pr_summary_block(text: str) -> str: - """Return the injected merged-PR summary block.""" - start = text.index("### Merged Pull Requests") - end = text.find("\n### ", start + len("### Merged Pull Requests")) - return text[start:] if end == -1 else text[start:end] - - -class TestCompactEntry: - def test_strips_commit_hash_link(self) -> None: - line = f"- Some feature {_commit()}" - assert _compact_entry(line) == "- Some feature" - - def test_strips_breaking_prefix(self) -> None: - line = f"- [**breaking**] Some change {_commit()}" - assert _compact_entry(line, strip_breaking=True) == "- Some change" - - def test_preserves_pr_links(self) -> None: - line = f"- Feature {_pr(42)} {_commit()}" - assert _compact_entry(line) == f"- Feature {_pr(42)}" - - def test_keeps_breaking_when_not_stripped(self) -> None: - line = f"- [**breaking**] Change {_commit()}" - assert _compact_entry(line) == "- [**breaking**] Change" - - -class TestMaxPrNumber: - def test_single_pr(self) -> None: - assert _max_pr_number(f"- Feature {_pr(42)}") == 42 - - def test_multiple_prs(self) -> None: - assert _max_pr_number(f"- Feature {_pr(10)} {_pr(99)}") == 99 - - def test_no_prs(self) -> None: - assert _max_pr_number("- Plain entry") == 0 - - -class TestSummarySections: - @staticmethod - def _changelog(entries: str) -> str: - """ - Create a sample changelog file containing a header, a 1.0.0 release section dated - 2026-01-01, and an "Added" subsection populated with the provided entries. - - Parameters: - entries (str): Markdown content to place under the "Added" subsection (should include any list markers or paragraphs). - - Returns: - str: The full changelog content as a string. - """ - return f"# Changelog\n\n## [1.0.0] - 2026-01-01\n\n### Added\n\n{entries}\n" - - def test_injects_pr_summary(self) -> None: - content = self._changelog(f"- Feature A {_pr(10)} {_commit()}\n- Plain commit {_commit('def5678', 'def5678deadbeef')}") - result = _inject_summary_sections(content) - summary_block = _merged_pr_summary_block(result) - assert "### Merged Pull Requests" in summary_block - # PR entry in summary (without commit hash). - assert f"- Feature A {_pr(10)}" in summary_block - assert "- Plain commit" not in summary_block - # Plain entry only appears once (in Added, not in summary). - plain_lines = [ln for ln in result.split("\n") if ln.startswith("- Plain commit")] - assert len(plain_lines) == 1 - - def test_injects_breaking_summary(self) -> None: - content = self._changelog(f"- [**breaking**] Big change {_pr(5)} {_commit()}") - result = _inject_summary_sections(content) - assert "### ⚠️ Breaking Changes" in result - assert "### Merged Pull Requests" in result - # Breaking section appears before Merged PRs. - assert result.index("### ⚠️ Breaking Changes") < result.index("### Merged Pull Requests") - - def test_injects_breaking_summary_from_marker_variants(self) -> None: - content = self._changelog(f"- **BREAKING** Big change {_pr(5)} {_commit()}") - result = _inject_summary_sections(content) - assert "### ⚠️ Breaking Changes" in result - assert f"- Big change {_pr(5)}" in result - - def test_injects_summary_from_star_bullets(self) -> None: - content = self._changelog(f"* [**breaking**] Star change {_pr(7)} {_commit()}") - result = _inject_summary_sections(content) - - assert "### ⚠️ Breaking Changes" in result - assert "### Merged Pull Requests" in result - assert f"* Star change {_pr(7)}" in result - - def test_pr_sorted_descending(self) -> None: - """ - Verifies that PRs in the injected "Merged Pull Requests" summary are sorted in descending order by PR number. - - Constructs a changelog with three entries containing PR links and confirms the summary lists them in order: highest PR number first. - """ - content = self._changelog( - f"- First {_pr(5)} {_commit('aaa1111', 'aaa1111deadbeef')}\n" - f"- Second {_pr(20)} {_commit('bbb2222', 'bbb2222deadbeef')}\n" - f"- Third {_pr(10)} {_commit('ccc3333', 'ccc3333deadbeef')}" - ) - result = _inject_summary_sections(content) - lines = result.split("\n") - pr_idx = next(i for i, ln in enumerate(lines) if "### Merged Pull Requests" in ln) - pr_lines = [ln for ln in lines[pr_idx + 1 :] if ln.startswith("- ")][:3] - assert "#20" in pr_lines[0] - assert "#10" in pr_lines[1] - assert "#5" in pr_lines[2] - - def test_no_summary_without_prs(self) -> None: - content = self._changelog(f"- Plain commit {_commit()}") - result = _inject_summary_sections(content) - assert "### Merged Pull Requests" not in result - - def test_idempotent(self) -> None: - content = self._changelog(f"- Feature {_pr(10)} {_commit()}") - first = _inject_summary_sections(content) - second = _inject_summary_sections(first) - assert first == second - - def test_idempotent_breaking_only(self) -> None: - """Breaking-only sections (no PR links) must not be double-injected.""" - content = self._changelog(f"- [**breaking**] Remove old API {_commit()}") - first = _inject_summary_sections(content) - assert "### ⚠️ Breaking Changes" in first - # "Merged Pull Requests" should NOT appear (no PR link). - assert "### Merged Pull Requests" not in first - second = _inject_summary_sections(first) - assert first == second - - def test_ignores_indented_sub_items(self) -> None: - content = self._changelog(f"- Feature {_commit()}\n - Sub-item {_pr(99)}") - result = _inject_summary_sections(content) - assert "### Merged Pull Requests" not in result - - def test_multiple_pr_links_preserved(self) -> None: - content = self._changelog(f"- Feature {_pr(10)} {_pr(20)} {_commit()}") - result = _inject_summary_sections(content) - summary_block = _merged_pr_summary_block(result) - assert f"- Feature {_pr(10)} {_pr(20)}" in summary_block - - def test_duplicate_summary_entries_are_collapsed(self) -> None: - entry = f"- Feature A {_pr(10)} {_commit()}" - content = self._changelog(f"{entry}\n{entry}") - result = _inject_summary_sections(content) - summary_block = _merged_pr_summary_block(result) - assert summary_block.count(f"- Feature A {_pr(10)}") == 1 - - @pytest.mark.parametrize( - ("message", "expected_summary"), - [ - ( - "perf!: tune kernels\n\nKeep arithmetic unchanged.\n\nBREAKING CHANGE: la-stack now requires Rust 1.98.1.", - "- la-stack now requires Rust 1.98.1.", - ), - ( - "fix: change result storage\n\nBREAKING CHANGE: Return `Vector<T>`.\nUse `into_array()` for raw storage.\n\nKeep explicit conversions.", - "- Return `Vector<T>`.\n Use `into_array()` for raw storage.\n\n Keep explicit conversions.", - ), - ("refactor!: remove legacy API", "- Remove legacy API"), - ( - "refactor!: change generic API\n\nBREAKING CHANGE: Replace <Old> with ``Vector<T>`` and `Result<T, E>`.", - "- Replace <Old> with ``Vector<T>`` and `Result<T, E>`.", - ), - ( - "refactor!: change generic API\n\nBREAKING CHANGE: Use this signature:\n\n```rust\nfn solve<T>() -> Result<T, Error>;\n```", - "- Use this signature:\n\n ```rust\n fn solve<T>() -> Result<T, Error>;\n ```", - ), - ( - "chore(deps): bump toolkit\n\nBREAKING CHANGE: Require a newer runtime.", - "- Require a newer runtime.", - ), - ], - ids=["msrv-footer", "multiline-footer", "bang-only", "code-spans", "fenced-code", "dependency-footer"], - ) - def test_template_preserves_breaking_descriptions(self, message: str, expected_summary: str) -> None: - """Render actual conventional footers without mutating Git history.""" - repo_root = Path(__file__).resolve().parents[2] - raw = run_safe_command( - "git-cliff", - [ - "--offline", - "--config", - str(repo_root / "cliff.toml"), - "--strip", - "footer", - "--with-commit", - message, - "--with-commit", - "fix: retain linked entry (#987)", - "HEAD..HEAD", - ], - cwd=repo_root, - ).stdout - - result = postprocess_text(raw) - - summary = result.split("### ⚠️ Breaking Changes\n\n", 1)[1].split("\n### ", 1)[0].strip() - assert summary == expected_summary - assert result.index("### ⚠️ Breaking Changes") < result.index("### Merged Pull Requests") - assert "- Retain linked entry [#987](https://github.com/acgetchell/la-stack/pull/987)" in _merged_pr_summary_block(result) - assert postprocess_text(result) == result - - -class TestListMarkerNormalization: - """MD004: consistent ``-`` list markers.""" - - def test_star_to_dash_at_column_zero(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("* item one\n* item two\n", encoding="utf-8") - postprocess(f) - result = f.read_text(encoding="utf-8") - assert "* item" not in result - assert "- item one" in result - assert "- item two" in result - - def test_star_to_dash_indented(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("- parent item\n * sub-item\n", encoding="utf-8") - postprocess(f) - assert " - sub-item" in f.read_text(encoding="utf-8") - - def test_star_in_bold_not_changed(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("Some **bold** text\n", encoding="utf-8") - postprocess(f) - assert "**bold**" in f.read_text(encoding="utf-8") - - def test_star_inside_code_block_not_changed(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("```text\n* keep me\n```\n", encoding="utf-8") - postprocess(f) - assert "* keep me" in f.read_text(encoding="utf-8") - - -class TestBlankLineBeforeList: - """MD032: blank lines around lists.""" - - def test_inserts_blank_before_list_after_prose(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("Some prose.\n- list item\n", encoding="utf-8") - postprocess(f) - assert f.read_text(encoding="utf-8") == "Some prose.\n\n- list item\n" - - def test_no_double_blank(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("Some prose.\n\n- list item\n", encoding="utf-8") - postprocess(f) - assert f.read_text(encoding="utf-8") == "Some prose.\n\n- list item\n" - - def test_no_blank_between_consecutive_items(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("- one\n- two\n", encoding="utf-8") - postprocess(f) - assert f.read_text(encoding="utf-8") == "- one\n- two\n" - - def test_no_blank_after_heading(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("### Added\n- item\n", encoding="utf-8") - postprocess(f) - # Heading directly followed by list is fine per MD032. - assert "\n\n- item" not in f.read_text(encoding="utf-8") - - -class TestIndentedHeadingNormalization: - """MD023: commit-body headings are rendered as prose, not nested headings.""" - - def test_indented_atx_heading_becomes_bold_prose(self) -> None: - assert _normalize_indented_heading(" ## Correctness Fixes") == " **Correctness Fixes**" - - def test_indented_atx_closing_sequence_becomes_bold_prose(self) -> None: - assert _normalize_indented_heading(" ### API Design ###") == " **API Design**" - - def test_column_zero_changelog_heading_is_preserved(self) -> None: - assert _normalize_indented_heading("### Added") == "### Added" - - def test_column_zero_entry_heading_becomes_level_four(self) -> None: - assert _normalize_entry_heading("## Duplicate Vertex Handling") == "#### Duplicate Vertex Handling" - - def test_column_zero_category_heading_is_preserved(self) -> None: - assert _normalize_entry_heading("### Fixed") == "### Fixed" - - def test_archives_heading_is_preserved(self) -> None: - assert _normalize_entry_heading("## Archives") == "## Archives" - - def test_bracketed_entry_heading_becomes_level_four(self) -> None: - assert _normalize_entry_heading("## [Notes]") == "#### [Notes]" - - def test_bracketed_version_like_entry_heading_becomes_level_four(self) -> None: - assert _normalize_entry_heading("## [1.2.3 Notes]") == "#### [1.2.3 Notes]" - - def test_release_heading_is_preserved(self) -> None: - assert _normalize_entry_heading("## [v1.2.3] - 2026-05-22") == "## [v1.2.3] - 2026-05-22" - - def test_prerelease_heading_is_preserved(self) -> None: - assert _normalize_entry_heading("## [1.2.3-rc.1+build.7]") == "## [1.2.3-rc.1+build.7]" - - def test_contextual_category_heading_becomes_level_four(self) -> None: - assert _normalize_entry_heading("### Fixed: Add rollback") == "#### Fixed: Add rollback" - - def test_normalized_heading_is_idempotent(self) -> None: - assert _normalize_indented_heading(" **Title**") == " **Title**" - - once = _normalize_indented_heading(" ## Correctness Fixes") - assert _normalize_indented_heading(once) == once - - def test_full_pipeline_normalizes_commit_body_headings(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text( - "# Changelog\n\n" - "## [1.0.0]\n\n" - "### Performance\n\n" - "- perf: improve Hilbert curve correctness\n\n" - " ## Correctness Fixes\n\n" - " - Add debug_assert guards\n\n" - " ## API Design\n\n" - " - Add HilbertError enum\n", - encoding="utf-8", - ) - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert " ## Correctness Fixes" not in result - assert " ## API Design" not in result - assert " **Correctness Fixes**" in result - assert " **API Design**" in result - assert "### Performance" in result - - def test_full_pipeline_normalizes_unindented_commit_body_headings(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text( - "# Changelog\n\n" - "## [1.0.0]\n\n" - "### Fixed\n\n" - "- Handle degenerate configurations [#116](https://github.com/acgetchell/causal-triangulations/pull/116)\n" - f" {_commit()}\n\n" - "## Duplicate Vertex Handling\n\n" - " - Add duplicate coordinate detection\n\n" - "### Fixed: Add rollback on cell creation failure\n\n" - " Add rollback mechanisms when cell creation fails.\n", - encoding="utf-8", - ) - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert "\n## Duplicate Vertex Handling" not in result - assert "\n### Fixed: Add rollback on cell creation failure" not in result - assert "#### Duplicate Vertex Handling" in result - assert "#### Fixed: Add rollback on cell creation failure" in result - assert "### Fixed" in result - - def test_existing_archive_normalization_preserves_fenced_headings(self) -> None: - text = ( - "# Changelog - 0.5.x\n\n" - "## [0.5.3] - 2025-10-31\n\n" - "### Fixed\n\n" - "```markdown\n" - "## Example Heading\n" - "### Fixed: Example\n" - "```\n\n" - "## Duplicate Vertex Handling\n" - ) - - result = normalize_entry_headings_text(text) - - assert "```markdown\n## Example Heading\n### Fixed: Example\n```" in result - assert "#### Duplicate Vertex Handling" in result - - def test_existing_archive_normalization_preserves_tilde_fenced_headings(self) -> None: - text = "# Changelog - 0.5.x\n\n~~~markdown\n## Example Heading\n~~~\n\n## Duplicate Vertex Handling\n" - - result = normalize_entry_headings_text(text) - - assert "~~~markdown\n## Example Heading\n~~~" in result - assert "#### Duplicate Vertex Handling" in result - - -class TestSquashHeadingNormalization: - """GitHub squash-body pseudo-commit headings are rendered as prose.""" - - def test_plain_summary_removes_links_and_conventional_prefix(self) -> None: - line = f"- fix: Improve benchmark output {_pr(42)} {_commit()}" - assert _plain_summary(line) == "improve benchmark output" - - def test_plain_summary_removes_breaking_marker(self) -> None: - line = f"- [**breaking**] feat!: Remove old API {_pr(42)} {_commit()}" - assert _plain_summary(line) == "remove old api" - - def test_squash_heading_parts_maps_kind_to_changelog_label(self) -> None: - assert _squash_heading_parts(" - perf(core): speed up predicates") == ( - " ", - "Performance", - "Speed up predicates", - ) - - def test_squash_heading_parts_ignores_commit_entries(self) -> None: - assert _squash_heading_parts(f"- fix: actual commit {_commit()}") is None - - def test_conventional_squash_heading_becomes_bold_prose(self) -> None: - assert _normalize_squash_heading("- fix: close the 4D retry collapse") == "**Fixed: Close the 4D retry collapse**" - - def test_nested_squash_heading_is_indented(self) -> None: - assert _normalize_squash_heading("- Changed: harden flip diagnostics", nested=True) == " **Changed: Harden flip diagnostics**" - - def test_commit_entry_is_preserved(self) -> None: - line = f"- fix: actual commit {_commit()}" - assert _normalize_squash_heading(line) == line - - def test_duplicate_squash_heading_matches_parent_summary(self) -> None: - parent = _plain_summary("- Instrument large-scale 4D debugging") - assert _is_duplicate_squash_heading("- feat: instrument large-scale 4D debugging", parent) - - def test_duplicate_squash_heading_rejects_distinct_heading(self) -> None: - parent = _plain_summary("- Instrument large-scale 4D debugging") - assert not _is_duplicate_squash_heading("- fix: close the 4D retry collapse", parent) - - def test_isolated_body_heading_requires_blank_neighbors(self) -> None: - lines = ["- Parent entry", "", "- fix: child heading", "", " - detail"] - assert _is_isolated_body_heading(lines, 2) - assert not _is_isolated_body_heading(lines, 4) - - def test_full_pipeline_drops_duplicate_squash_heading(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text( - "# Changelog\n\n" - "## [1.0.0]\n\n" - "### Added\n\n" - f"- Instrument large-scale 4D debugging {_commit('3af976e', '3af976ec2f7c33d49803b24ab8f1a7da598fea0b')}\n\n" - "* feat: instrument large-scale 4D debugging\n\n" - " - Thread cavity-touched cells through insertion.\n\n" - "* fix: close the 4D bulk repair retry collapse\n\n" - " - Raise the D>=4 per-insertion repair budget.\n", - encoding="utf-8", - ) - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert "feat: instrument large-scale 4D debugging" not in result - assert "**Fixed: Close the 4D bulk repair retry collapse**" in result - assert " - Thread cavity-touched cells through insertion." in result - - def test_full_pipeline_resets_parent_summary_at_version_heading(self, tmp_path: Path) -> None: - """Version headings reset duplicate-squash tracking between releases.""" - f = tmp_path / "CHANGELOG.md" - f.write_text( - "# Changelog\n\n" - "## [1.0.0]\n\n" - "### Added\n\n" - f"- Repeatable summary {_commit()}\n\n" - "## [0.9.0]\n\n" - "- fixed: repeatable summary\n\n" - " - Preserve this historical squash-body heading.\n", - encoding="utf-8", - ) - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert "**Fixed: Repeatable summary**" in result - assert " - Preserve this historical squash-body heading." in result - - def test_full_pipeline_preserves_non_isolated_conventional_bullets(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text( - "# Changelog\n\n" - "## [1.0.0]\n\n" - "### Documentation\n\n" - f"- Update workflow docs {_commit()}\n\n" - " - Added: `just help-workflows` references throughout\n" - " - Expanded: Testing commands\n", - encoding="utf-8", - ) - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert " - Added: `just help-workflows` references throughout" in result - assert "**Added: `just help-workflows`" not in result - - def test_full_pipeline_deindents_children_after_squash_heading(self) -> None: - content = ( - "# Changelog\n\n" - "## [1.0.0]\n\n" - "### Added\n\n" - f"- Identity-based SoS perturbation {_pr(272)} {_commit('a125d98', 'a125d98deadbeef')}\n\n" - f" - feat: Canonical vertex ordering details {_pr(266)}\n\n" - " - Add canonical_points module with sorted_cell_points helpers\n" - ) - result = postprocess_text(content) - assert f" **Added: Canonical vertex ordering details {_pr(266)}**" in result - assert "\n - Add canonical_points module with sorted_cell_points helpers\n" in result - assert "\n - Add canonical_points module" not in result - - -class TestCodeBlockLanguage: - def test_process_code_fence_opens_and_tags_bare_fence(self) -> None: - result: list[str] = [] - - handled, active_fence = _process_code_fence("```", result, active_fence=None, next_line="let x = 1;") - - assert handled - assert active_fence == _CodeFence(delimiter="`", length=3) - assert result == ["```text"] - - def test_process_code_fence_closes_existing_block(self) -> None: - result: list[str] = [] - - handled, active_fence = _process_code_fence( - "```", - result, - active_fence=_CodeFence(delimiter="`", length=3), - next_line=None, - ) - - assert handled - assert active_fence is None - assert result == ["```"] - - def test_process_code_fence_adds_blank_after_closing_fence(self) -> None: - result: list[str] = [] - - handled, active_fence = _process_code_fence( - "```", - result, - active_fence=_CodeFence(delimiter="`", length=3), - next_line="following prose", - ) - - assert handled - assert active_fence is None - assert result == ["```", ""] - - def test_process_code_fence_ignores_regular_line(self) -> None: - result: list[str] = [] - - handled, active_fence = _process_code_fence("regular text", result, active_fence=None, next_line=None) - - assert not handled - assert active_fence is None - assert result == [] - - def test_tilde_fence_is_supported_and_tagged(self) -> None: - result: list[str] = [] - - handled, active_fence = _process_code_fence("~~~~", result, active_fence=None, next_line="code") - - assert handled - assert active_fence == _CodeFence(delimiter="~", length=4) - assert result == ["~~~~text"] - - def test_shorter_or_different_delimiter_does_not_close_fence(self) -> None: - active = _CodeFence(delimiter="`", length=4) - - handled_short, still_active = _process_code_fence("```", [], active_fence=active, next_line=None) - handled_tilde, still_active = _process_code_fence("~~~~", [], active_fence=still_active, next_line=None) - handled_close, closed = _process_code_fence("`````", [], active_fence=still_active, next_line=None) - - assert not handled_short - assert not handled_tilde - assert still_active == active - assert handled_close - assert closed is None - - def test_adds_language_to_bare_fence(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text(" ```\n let x = 1;\n ```\n", encoding="utf-8") - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert "```text" in result - - def test_preserves_existing_language(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("```rust\nlet x = 1;\n```\n", encoding="utf-8") - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert "```rust" in result - assert "```text" not in result - - def test_no_reflow_inside_code_block(self, tmp_path: Path) -> None: - long_code = " let very_long = " + "a" * 200 + ";" - f = tmp_path / "CHANGELOG.md" - f.write_text(f"```rust\n{long_code}\n```\n", encoding="utf-8") - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert long_code in result - - def test_no_reflow_or_heading_rewrite_inside_tilde_block(self, tmp_path: Path) -> None: - long_code = "## " + "code " * 50 - f = tmp_path / "CHANGELOG.md" - f.write_text(f"~~~markdown\n{long_code.rstrip()}\n~~~\n## Outside\n", encoding="utf-8") - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert long_code.rstrip() in result - assert "~~~markdown" in result - assert "#### Outside" in result - - def test_adds_blank_after_code_block_before_prose(self, tmp_path: Path) -> None: - f = tmp_path / "CHANGELOG.md" - f.write_text("```text\ncode\n```\nfollowing prose\n", encoding="utf-8") - - postprocess(f) - - assert f.read_text(encoding="utf-8") == "```text\ncode\n```\n\nfollowing prose\n" - - -class TestIntegration: - def test_full_pipeline_is_idempotent(self) -> None: - content = "# Changelog\n\n## [1.0.0] - 2026-01-01\n\n### Fixed\n\n- fixed: preserve the generated body\n\n - Historical detail.\n" - - once = postprocess_text(content) - - assert "\n - Historical detail.\n" in once - assert postprocess_text(once) == once - - def test_full_changelog_reflow(self, tmp_path: Path) -> None: - """Simulate a realistic changelog snippet with long lines.""" - long_entry = ( - "- Use exact arithmetic [#235](https://github.com/acgetchell/delaunay/pull/235) " - "[#236](https://github.com/acgetchell/delaunay/pull/236) " - "[`a62437f`](https://github.com/acgetchell/delaunay/commit/a62437f25c27259f145d3c193ce149ee14b421c7)" - ) - long_body = " " + "word " * 40 - content = f"# Changelog\n\n## [0.7.2]\n\n### Added\n\n{long_entry}\n\n{long_body.rstrip()}\n\n" - - f = tmp_path / "CHANGELOG.md" - f.write_text(content, encoding="utf-8") - - postprocess(f) - - result = f.read_text(encoding="utf-8") - for line in result.split("\n"): - if line.strip(): - assert len(line) <= 160 or "](" in line or "http" in line, f"Line too long ({len(line)}): {line[:80]}..." - - def test_summary_sections_in_full_pipeline(self, tmp_path: Path) -> None: - """Summary sections are injected and survive reflow.""" - entry = f"- Feature {_pr(42)} {_commit()}" - content = f"# Changelog\n\n## [1.0.0] - 2026-01-01\n\n### Added\n\n{entry}\n" - f = tmp_path / "CHANGELOG.md" - f.write_text(content, encoding="utf-8") - - postprocess(f) - - result = f.read_text(encoding="utf-8") - assert "### Merged Pull Requests" in result - assert "### Added" in result - # Summary appears before categorised sections. - assert result.index("### Merged Pull Requests") < result.index("### Added") diff --git a/scripts/tests/test_tag_release.py b/scripts/tests/test_tag_release.py deleted file mode 100644 index b58122d..0000000 --- a/scripts/tests/test_tag_release.py +++ /dev/null @@ -1,492 +0,0 @@ -"""Tests for tag_release.py — annotated tag creation with size-limit handling.""" - -import subprocess -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -import tag_release -from tag_release import ( - _GITHUB_TAG_ANNOTATION_LIMIT, - _get_repo_url, - _github_anchor, - extract_changelog_section, - find_changelog, - parse_version, - validate_semver, -) - -if TYPE_CHECKING: - from pathlib import Path - -# --------------------------------------------------------------------------- -# SemVer validation -# --------------------------------------------------------------------------- - - -class TestValidateSemver: - @pytest.mark.parametrize( - "version", - [ - "v0.1.0", - "v1.0.0", - "v12.34.56", - "v1.2.3-rc.1", - "v1.2.3-alpha", - "v1.2.3+build.42", - "v1.2.3-beta.1+build.123", - "v1.0.0-1a", # digit-prefixed alphanumeric prerelease - "v1.0.0-0a", # leading zero OK when not purely numeric - "v1.0.0-1a.2b", # dot-separated digit-prefixed IDs - ], - ) - def test_valid_versions(self, version: str) -> None: - validate_semver(version) # should not raise - - @pytest.mark.parametrize( - "version", - [ - "0.1.0", # missing v prefix - "v1", # incomplete - "v1.2", # missing patch - "v01.2.3", # leading zero - "v1.02.3", # leading zero - "v1.2.03", # leading zero - "v1.0.0-01", # leading zero in purely numeric prerelease - "vfoo", # garbage - "", # empty - ], - ) - def test_invalid_versions(self, version: str) -> None: - with pytest.raises(ValueError, match="SemVer format"): - validate_semver(version) - - -class TestParseVersion: - def test_strips_v_prefix(self) -> None: - assert parse_version("v1.2.3") == "1.2.3" - - def test_no_prefix(self) -> None: - assert parse_version("1.2.3") == "1.2.3" - - -# --------------------------------------------------------------------------- -# Changelog helpers -# --------------------------------------------------------------------------- - - -_SAMPLE_CHANGELOG = """\ -# Changelog - -## [0.2.0] - 2025-03-01 - -### Added - -- Exact determinant sign via `det_sign_exact()` - -### Changed - -- Bump version to 0.2.0 - -## [0.1.3] - 2025-02-15 - -### Fixed - -- Minor doc typo -""" - - -class TestFindChangelog: - def test_finds_in_current_dir(self, tmp_path: Path) -> None: - (tmp_path / "CHANGELOG.md").write_text("# Changelog\n", encoding="utf-8") - result = find_changelog(tmp_path) - assert result.name == "CHANGELOG.md" - - def test_finds_in_parent_dir(self, tmp_path: Path) -> None: - (tmp_path / "CHANGELOG.md").write_text("# Changelog\n", encoding="utf-8") - child = tmp_path / "scripts" - child.mkdir() - result = find_changelog(child) - assert result.name == "CHANGELOG.md" - - def test_raises_when_missing(self, tmp_path: Path) -> None: - with pytest.raises(FileNotFoundError, match=r"CHANGELOG\.md not found"): - find_changelog(tmp_path) - - -class TestExtractChangelogSection: - def test_extracts_section(self, tmp_path: Path) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_SAMPLE_CHANGELOG, encoding="utf-8") - - section, source = extract_changelog_section(changelog, "0.2.0") - assert "det_sign_exact" in section - assert "Bump version" in section - assert source == changelog - # Should not include content from 0.1.3 - assert "Minor doc typo" not in section - - def test_extracts_older_section(self, tmp_path: Path) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_SAMPLE_CHANGELOG, encoding="utf-8") - - section, source = extract_changelog_section(changelog, "0.1.3") - assert "Minor doc typo" in section - assert "det_sign_exact" not in section - assert source == changelog - - def test_raises_for_missing_version(self, tmp_path: Path) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_SAMPLE_CHANGELOG, encoding="utf-8") - - with pytest.raises(LookupError, match="No changelog section found"): - extract_changelog_section(changelog, "9.9.9") - - def test_raises_for_empty_section(self, tmp_path: Path) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text("# Changelog\n\n## [1.0.0] - 2025-01-01\n\n## [0.9.0] - 2024-12-01\n", encoding="utf-8") - - with pytest.raises(LookupError, match="empty"): - extract_changelog_section(changelog, "1.0.0") - - -# --------------------------------------------------------------------------- -# GitHub anchor generation -# --------------------------------------------------------------------------- - - -class TestGitHubAnchor: - """Verify _github_anchor matches github-slugger output.""" - - def test_bracketed_heading(self, tmp_path: Path) -> None: - """Heading ``## [1.0.0] - 2025-01-01`` should strip brackets and dots.""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text( - "# Changelog\n\n## [1.0.0] - 2025-01-01\n\n- Item\n", - encoding="utf-8", - ) - assert _github_anchor(changelog, "1.0.0") == "100---2025-01-01" - - def test_plain_v_heading(self, tmp_path: Path) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text( - "# Changelog\n\n## v0.2.0\n\n- Item\n", - encoding="utf-8", - ) - assert _github_anchor(changelog, "0.2.0") == "v020" - - def test_fallback_when_not_found(self, tmp_path: Path) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text("# Changelog\n", encoding="utf-8") - assert _github_anchor(changelog, "9.9.9") == "v999" - - def test_does_not_match_prerelease_heading(self, tmp_path: Path) -> None: - """Looking for 1.0.0 must not match ## [1.0.0-rc.1].""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text( - "# Changelog\n\n## [1.0.0-rc.1] - 2025-01-01\n\n- Item\n", - encoding="utf-8", - ) - # Should fall back since no exact 1.0.0 heading exists - assert _github_anchor(changelog, "1.0.0") == "v100" - - -# --------------------------------------------------------------------------- -# Tag size limit handling -# --------------------------------------------------------------------------- - - -class TestTagSizeLimit: - def test_small_section_uses_full_content(self, tmp_path: Path) -> None: - """A normal-sized changelog section should be used as the tag message.""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_SAMPLE_CHANGELOG, encoding="utf-8") - - section, _source = extract_changelog_section(changelog, "0.2.0") - assert len(section.encode("utf-8")) < _GITHUB_TAG_ANNOTATION_LIMIT - - def test_oversized_section_detected(self, tmp_path: Path) -> None: - """Synthetic oversized changelog should exceed the limit.""" - # Build content > 125KB - lines = [f"- Item number {i}" for i in range(20_000)] - big_section = "\n".join(lines) - - changelog_text = f"# Changelog\n\n## [1.0.0] - 2025-01-01\n\n{big_section}\n\n## [0.9.0] - 2024-12-01\n\n- Old item\n" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(changelog_text, encoding="utf-8") - - section, _source = extract_changelog_section(changelog, "1.0.0") - assert len(section.encode("utf-8")) > _GITHUB_TAG_ANNOTATION_LIMIT - - -# --------------------------------------------------------------------------- -# create_tag workflow (mocked git) -# --------------------------------------------------------------------------- - - -class TestCreateTag: - @pytest.fixture(autouse=True) - def _matching_package_version(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(tag_release, "_package_version", lambda _changelog: "1.0.0") - - def test_main_preserves_captured_git_diagnostics( - self, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], - ) -> None: - def fail_create_tag(_version: str, *, force: bool = False) -> None: - del force - raise subprocess.CalledProcessError( - 128, - ["git", "tag", "v1.0.0"], - output="tag stdout", - stderr="tag rejected by hook", - ) - - monkeypatch.setattr(tag_release, "create_tag", fail_create_tag) - - assert tag_release.main(["v1.0.0"]) == 1 - captured = capsys.readouterr() - assert "git tag v1.0.0" in captured.err - assert "tag stdout" in captured.err - assert "tag rejected by hook" in captured.err - assert "Traceback" not in captured.err - - def test_next_step_sets_release_title( - self, - tmp_path: Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - changelog = tmp_path / "CHANGELOG.md" - with ( - patch("tag_release._tag_exists", return_value=False), - patch("tag_release.find_changelog", return_value=changelog), - patch( - "tag_release.extract_changelog_section", - return_value=("### Added\n\n- Something new", changelog), - ), - patch("tag_release.run_git_command_with_input"), - ): - tag_release.create_tag("v1.0.0") - - assert "gh release create v1.0.0 --title v1.0.0 --notes-from-tag" in capsys.readouterr().out - - @patch("tag_release.run_git_command_with_input") - @patch("tag_release._tag_exists", return_value=False) - @patch("tag_release.find_changelog") - @patch("tag_release.extract_changelog_section") - def test_creates_annotated_tag( - self, - mock_extract: MagicMock, - mock_find: MagicMock, - mock_exists: MagicMock, - mock_git_input: MagicMock, - tmp_path: Path, - ) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_SAMPLE_CHANGELOG, encoding="utf-8") - mock_find.return_value = changelog - mock_extract.return_value = ("### Added\n\n- Something new", changelog) - - tag_release.create_tag("v1.0.0") - mock_exists.assert_called_once_with("v1.0.0") - - mock_git_input.assert_called_once() - call_args = mock_git_input.call_args - assert call_args[0][0] == ["tag", "-a", "v1.0.0", "-F", "-", "--cleanup=verbatim"] - assert "Something new" in call_args[1]["input_data"] - - @patch("tag_release.run_git_command_with_input") - @patch("tag_release._get_repo_url", return_value="https://github.com/acgetchell/la-stack") - @patch("tag_release._tag_exists", return_value=False) - @patch("tag_release.find_changelog") - def test_oversized_creates_reference_tag( - self, - mock_find: MagicMock, - mock_exists: MagicMock, - mock_url: MagicMock, - mock_git_input: MagicMock, - tmp_path: Path, - ) -> None: - """When changelog exceeds 125KB, tag message should be a short reference.""" - lines = [f"- Item number {i}" for i in range(20_000)] - big_section = "\n".join(lines) - changelog_text = f"# Changelog\n\n## [1.0.0] - 2025-01-01\n\n{big_section}\n\n## [0.9.0] - 2024-12-01\n\n- Old\n" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(changelog_text, encoding="utf-8") - mock_find.return_value = changelog - - # Patch extract to return the real oversized content - with patch("tag_release.extract_changelog_section", return_value=(big_section, changelog)): - tag_release.create_tag("v1.0.0") - - mock_exists.assert_called_once_with("v1.0.0") - mock_url.assert_called_once_with() - mock_git_input.assert_called_once() - tag_message = mock_git_input.call_args[1]["input_data"] - assert "See full changelog" in tag_message - assert "CHANGELOG.md" in tag_message - assert len(tag_message) < 1000 - - @patch("tag_release._tag_exists", return_value=True) - def test_existing_tag_without_force_exits(self, mock_exists: MagicMock) -> None: - with pytest.raises(SystemExit): - tag_release.create_tag("v1.0.0", force=False) - mock_exists.assert_called_once_with("v1.0.0") - - @patch("tag_release.run_git_command_with_input") - @patch("tag_release._tag_exists", return_value=True) - @patch("tag_release.find_changelog") - @patch("tag_release.extract_changelog_section") - def test_force_recreates_tag( - self, - mock_extract: MagicMock, - mock_find: MagicMock, - mock_exists: MagicMock, - mock_git_input: MagicMock, - tmp_path: Path, - ) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_SAMPLE_CHANGELOG, encoding="utf-8") - mock_find.return_value = changelog - mock_extract.return_value = ("### Fixed\n\n- Bug fix", changelog) - - tag_release.create_tag("v1.0.0", force=True) - - mock_exists.assert_called_once_with("v1.0.0") - mock_git_input.assert_called_once() - assert mock_git_input.call_args[0][0] == ["tag", "-f", "-a", "v1.0.0", "-F", "-", "--cleanup=verbatim"] - - @patch("tag_release._tag_exists", return_value=True) - @patch("tag_release.find_changelog") - @patch("tag_release.extract_changelog_section", side_effect=LookupError("not found")) - @patch("tag_release.run_git_command_with_input") - def test_force_does_not_delete_tag_if_changelog_fails( - self, - mock_git_input: MagicMock, - mock_extract: MagicMock, - mock_find: MagicMock, - mock_exists: MagicMock, - tmp_path: Path, - ) -> None: - """Tag must not be deleted if changelog extraction fails.""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text("# Changelog\n", encoding="utf-8") - mock_find.return_value = changelog - - with pytest.raises(LookupError): - tag_release.create_tag("v1.0.0", force=True) - - mock_exists.assert_called_once_with("v1.0.0") - mock_extract.assert_called_once() - mock_git_input.assert_not_called() - - def test_invalid_remote_does_not_replace_existing_tag( - self, - tmp_path: Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - """Oversized-tag fallback validates its remote before mutating a tag.""" - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text("# Changelog\n", encoding="utf-8") - section = "x" * (_GITHUB_TAG_ANNOTATION_LIMIT + 1) - marker = "release-token" - - with ( - patch("tag_release._tag_exists", return_value=True), - patch("tag_release.find_changelog", return_value=changelog), - patch("tag_release.extract_changelog_section", return_value=(section, changelog)), - patch("tag_release.run_git_command") as mock_git, - patch("tag_release.run_git_command_with_input") as mock_git_input, - ): - mock_git.return_value.stdout = f"https://github.com/acgetchell/la-stack.git?token={marker}" - - with pytest.raises(ValueError, match="query parameters") as exc_info: - tag_release.create_tag("v1.0.0", force=True) - - mock_git_input.assert_not_called() - output = capsys.readouterr() - assert marker not in str(exc_info.value) - assert marker not in output.out - assert marker not in output.err - - def test_rejects_tag_that_differs_from_cargo_before_git(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(_SAMPLE_CHANGELOG, encoding="utf-8") - mock_exists = MagicMock() - monkeypatch.setattr(tag_release, "find_changelog", lambda: changelog) - monkeypatch.setattr(tag_release, "_package_version", lambda _changelog: "1.0.1") - monkeypatch.setattr(tag_release, "_tag_exists", mock_exists) - - with pytest.raises(ValueError, match="does not match Cargo package version"): - tag_release.create_tag("v1.0.0") - - mock_exists.assert_not_called() - - -class TestRepoUrl: - @pytest.mark.parametrize( - ("raw", "expected"), - [ - ("git@github.com:acgetchell/la-stack.git", "https://github.com/acgetchell/la-stack"), - ("https://github.com/acgetchell/la-stack.git", "https://github.com/acgetchell/la-stack"), - ("ssh://git@github.com/acgetchell/la-stack.git", "https://github.com/acgetchell/la-stack"), - ], - ) - @patch("tag_release.run_git_command") - def test_normalizes_github_remotes(self, mock_git: MagicMock, raw: str, expected: str) -> None: - mock_git.return_value.stdout = raw - - assert _get_repo_url() == expected - - @patch("tag_release.run_git_command") - def test_rejects_remote_urls_with_credentials(self, mock_git: MagicMock) -> None: - raw = "https://user:secret-token@github.com/acgetchell/la-stack.git" - mock_git.return_value.stdout = raw - - with pytest.raises(ValueError, match="contain credentials") as exc_info: - _get_repo_url() - - assert "secret-token" not in str(exc_info.value) - assert raw not in str(exc_info.value) - - @pytest.mark.parametrize( - "raw", - [ - "https://github.com/acgetchell/la-stack.git?token=secret-token", - "https://github.com/acgetchell/la-stack.git#secret-token", - ], - ) - @patch("tag_release.run_git_command") - def test_rejects_query_and_fragment_without_echoing_them(self, mock_git: MagicMock, raw: str) -> None: - mock_git.return_value.stdout = raw - - with pytest.raises(ValueError, match="query parameters or fragments") as exc_info: - _get_repo_url() - - assert "secret-token" not in str(exc_info.value) - assert raw not in str(exc_info.value) - - @pytest.mark.parametrize( - "raw", - [ - "http://github.com/acgetchell/la-stack.git", - "https://example.com/acgetchell/la-stack.git", - "git@example.com:acgetchell/la-stack.git", - "ssh://developer@github.com/acgetchell/la-stack.git", - "https://github.com/acgetchell/la-stack/extra.git", - "file:///tmp/la-stack.git", - "/home/example/la-stack.git", - "../la-stack.git", - "https://[malformed", - ], - ) - @patch("tag_release.run_git_command") - def test_rejects_unsupported_remotes_without_echoing_them(self, mock_git: MagicMock, raw: str) -> None: - mock_git.return_value.stdout = raw - - with pytest.raises(ValueError, match="Origin remote") as exc_info: - _get_repo_url() - - assert raw not in str(exc_info.value) diff --git a/scripts/tests/test_update_python_dev_pins.py b/scripts/tests/test_update_python_dev_pins.py index 1ff027b..8a39c34 100644 --- a/scripts/tests/test_update_python_dev_pins.py +++ b/scripts/tests/test_update_python_dev_pins.py @@ -1,15 +1,13 @@ """Tests for resolver-backed Python development-tool pin updates.""" import subprocess -from typing import TYPE_CHECKING +import tomllib +from pathlib import Path import pytest import update_python_dev_pins -if TYPE_CHECKING: - from pathlib import Path - def project_text(*requirements: str) -> str: """Return a minimal project with exact development-tool pins.""" @@ -36,6 +34,33 @@ def test_parse_project_accepts_exact_simple_dev_pins() -> None: ] +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")) @@ -61,12 +86,17 @@ def test_parse_resolution_rejects_missing_direct_tool() -> None: 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" - pyproject.write_text(project_text("ruff==0.16.2", "semgrep==1.172.0"), encoding="utf-8") + 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]: diff --git a/scripts/update_python_dev_pins.py b/scripts/update_python_dev_pins.py index d857e3d..83fa4ae 100644 --- a/scripts/update_python_dev_pins.py +++ b/scripts/update_python_dev_pins.py @@ -56,7 +56,7 @@ def _python_floor(project: dict[str, object]) -> str: def _dev_pins(groups: dict[str, object]) -> list[DevPin]: - """Return validated exact pins from dependency-groups.dev.""" + """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" @@ -68,8 +68,14 @@ def _dev_pins(groups: dict[str, object]) -> list[DevPin]: 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" + 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: @@ -162,7 +168,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: "--pyproject", type=Path, default=Path("pyproject.toml"), - help="project manifest containing exact dependency-groups.dev pins", + help="project manifest containing direct exact dependency-groups.dev pins", ) return parser.parse_args(argv) diff --git a/semgrep.yaml b/semgrep.yaml index a98ed50..63328a0 100644 --- a/semgrep.yaml +++ b/semgrep.yaml @@ -871,6 +871,7 @@ rules: - "/tests/semgrep/docs/**/*.md" exclude: - "/docs/archive/**" + - "/docs/archives/changelog/**" pattern-regex: '\.(?:unwrap(?:_[A-Za-z0-9_]+)?|expect)\s*\(' - id: la-stack.rust.no-box-dyn-error-in-doctests diff --git a/uv.lock b/uv.lock index 4f4f4fb..757d1a7 100644 --- a/uv.lock +++ b/uv.lock @@ -461,6 +461,7 @@ source = { editable = "." } dev = [ { name = "actionlint-py" }, { name = "pytest" }, + { name = "research-repo-tools" }, { name = "ruff" }, { name = "semgrep" }, { name = "shellcheck-py" }, @@ -468,6 +469,9 @@ dev = [ { name = "ty" }, { name = "yamllint" }, ] +tooling = [ + { name = "research-repo-tools" }, +] [package.metadata] @@ -475,6 +479,7 @@ dev = [ dev = [ { name = "actionlint-py", specifier = "==1.7.12.24" }, { name = "pytest", specifier = "==9.1.1" }, + { name = "research-repo-tools", specifier = "==0.1.7" }, { name = "ruff", specifier = "==0.16.6" }, { name = "semgrep", specifier = "==1.176.1" }, { name = "shellcheck-py", specifier = "==0.11.0.1" }, @@ -482,6 +487,7 @@ dev = [ { name = "ty", specifier = "==0.0.79" }, { name = "yamllint", specifier = "==1.38.0" }, ] +tooling = [{ name = "research-repo-tools", specifier = "==0.1.7" }] [[package]] name = "markdown-it-py" @@ -915,6 +921,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "research-repo-tools" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pyyaml" }, + { name = "rust-just" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/bf/ee8d41dab3fe80da99147b7caebae7eb72a4001606fef588273bd369312b/research_repo_tools-0.1.7.tar.gz", hash = "sha256:2a8aa607a6ffccf2c5e2a48565e1eceb4ab3f361ebbf2136d6fa5a197ce68a19", size = 540024, upload-time = "2026-09-26T20:01:09.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/e7/58faeb7238ea1276471670ca5e746834da07b67e201eb9ef4c8a5a24f8d3/research_repo_tools-0.1.7-py3-none-any.whl", hash = "sha256:ff60397c563d5a7a89d73ddba219e741dd3f4936bfa001ea498c4607aea05cb6", size = 232872, upload-time = "2026-09-26T20:01:07.556Z" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -1046,6 +1066,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, ] +[[package]] +name = "rust-just" +version = "1.58.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/d5/794e00d644caa238e931d4991d69951401492de55aa19334fdac72322ce2/rust_just-1.58.0.tar.gz", hash = "sha256:3226a7f201bf9a7766e676d32c5d8c5ae4fe0e3642628e0a9d4b62394dd0cefe", size = 2006103, upload-time = "2026-08-04T02:59:21.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/12/66b02e33ada6fe66f77091f298de83ae8221ff0ac04472a16adea742688a/rust_just-1.58.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cf620ee15c9f12246ba628000d96f338f395d03c770dd55826ceed26881f9843", size = 2289171, upload-time = "2026-08-04T02:58:57.345Z" }, + { url = "https://files.pythonhosted.org/packages/46/2f/d223d8d3f6e686365d7e0377c7df276dbb0afa407ed5749c654920906152/rust_just-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:95029add8ceab772ce9e3542fad9543d13d142513c7898987fd7ad1317090101", size = 2129203, upload-time = "2026-08-04T02:58:59.075Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3c/4e548af5d2e23d4f5946e226f8ebf7daac1ee1803f9739d0a4193d63dfb0/rust_just-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1483226b767db4a0ea523eedd165667663115514c92a50577c9ab84aba76352", size = 2217447, upload-time = "2026-08-04T02:59:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f3/8fbbdc706a52c875a46dd4c33d8d0220b76a712ed1aff68cc47590c1eabf/rust_just-1.58.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6b7536e87b0fb4e9f0856488a296977dabaf2f3b6761bf084ec70ed6c8107255", size = 2188508, upload-time = "2026-08-04T02:59:02.27Z" }, + { url = "https://files.pythonhosted.org/packages/f8/df/218a4b42a5bc37d575cdf7aef2cdaea1d64679aa542361ca157ab15e3e65/rust_just-1.58.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:77df7f183f54a3be94e331b98cbd27f299368ad2a8b208f3ac49af76ea0954b9", size = 2390783, upload-time = "2026-08-04T02:59:03.918Z" }, + { url = "https://files.pythonhosted.org/packages/86/05/001c035ba15b19a81039643506e62e5b82a80d4461617ec8dcac59c7dd7e/rust_just-1.58.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54521c790615033f92e2336b73f04017fdb3e8f7e57143011b2cb7b17ea74a36", size = 2504440, upload-time = "2026-08-04T02:59:05.175Z" }, + { url = "https://files.pythonhosted.org/packages/26/90/187046be529d840eec91ac51ecfd0fb8fc911f1771e0e51d85c3b03cd248/rust_just-1.58.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:83dfc3d8b4802d7e21e8eefb102df049f9b171b998617735579d717bf01d098e", size = 2462963, upload-time = "2026-08-04T02:59:06.634Z" }, + { url = "https://files.pythonhosted.org/packages/31/f1/ddd312fe9e792bf3dde3bf7669e77a1ca069cebba864ee2a625b757207c3/rust_just-1.58.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb4e2cc32ac8f3ec540129b09b818403cbbca44d84ebda4bb9e4b045dbd48a47", size = 2381777, upload-time = "2026-08-04T02:59:07.852Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7d/98fa3f4af78ea2339109d5fc70751bae918ad8438b3205fd9f7d532e8021/rust_just-1.58.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9085e86d98d15272e00721b95d125b63f28de0adc842127271ef3c1223bf150a", size = 2238418, upload-time = "2026-08-04T02:59:09.098Z" }, + { url = "https://files.pythonhosted.org/packages/0a/64/4059516b02862be59d37f389e64a77c6252effcfeabeb932a6793bc2ed81/rust_just-1.58.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2c6a9facc5aab021d1d54cc03fc406ab49ab7cbe802f233ba99db6251bfdb3b0", size = 2219864, upload-time = "2026-08-04T02:59:10.387Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8d/6c79364ce1973b405d90127f552fd21a372a1322a62d03af9f4fcc2199dc/rust_just-1.58.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:73f12c197573a945b6dc17e77d7c2bd3c1b297f9ddba9b2d64f19b79d9c5568c", size = 2210179, upload-time = "2026-08-04T02:59:12.123Z" }, + { url = "https://files.pythonhosted.org/packages/69/b4/287008f012ec8fd6022da97e08abc147e195577c23ca4e919ceac3609ad9/rust_just-1.58.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d4b5d2bf0f05b87d3bfdcf698583f4f64047c6b4f1f49bc002b57d21db3ed980", size = 2352630, upload-time = "2026-08-04T02:59:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1f/cb3c22d56649417792ed6c1e7b7d0835cc3f440a2f0a8ea332a2dbc9d26e/rust_just-1.58.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:4f95cccd7f85cb55da30d362d748747e89dfd24869973310168214bc7331cbac", size = 2413427, upload-time = "2026-08-04T02:59:15.204Z" }, + { url = "https://files.pythonhosted.org/packages/50/c8/1a3880ee39ee25dc2d8f1e5f965a2bed402859ada434d19b131d0cd86beb/rust_just-1.58.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6c0cdfe4db533d37a3ac0fd790b3fa21f8e3d913cc218fcde84c40bc02019667", size = 2461925, upload-time = "2026-08-04T02:59:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/2f/56/3cc19726dec58831872bbedfac31f57e68db1c0bedbb4102714f0a45ef66/rust_just-1.58.0-py3-none-win32.whl", hash = "sha256:0e9baefc1464ab81a337f559e25a5c0c7f3d5c993d2fcd9ba1bc2585ab4bbac2", size = 2137004, upload-time = "2026-08-04T02:59:18.417Z" }, + { url = "https://files.pythonhosted.org/packages/fa/31/234039ddfd4fc10dc16679063d10848423fee6ab13aac3f5d8ce5d1d8b27/rust_just-1.58.0-py3-none-win_amd64.whl", hash = "sha256:145cea724b6d981422ad5357ac6fec53be62b8146ac67a3921c89a0db152887d", size = 2358021, upload-time = "2026-08-04T02:59:19.817Z" }, +] + [[package]] name = "semantic-version" version = "2.10.0"