Skip to content
rlsbl.changelog.validate
On this page

Validates JSONL changelog entries against git history: hash resolution, range checking, commit coverage with monorepo filtering, orphans, and schema.

#rlsbl.changelog.validate

#rlsbl.changelog.validate

Validates JSONL changelog entries against git history: hash resolution, range checking, commit coverage, orphan detection, and schema conformance.

#_get_batch_limits_config

python
def _get_batch_limits_config(config) -> dict

Return the resolved batch_limits config with defaults applied.

config is the project config dict (already loaded). Reads the raw batch_limits section via :func:get_changelog_validation_config and guarantees that all three expected keys are present with sane types:

  • max_commits_per_entry (int)
  • max_entries_per_commit (int)
  • exclusions (list)

A missing key keeps its documented default. A key that is present but has the wrong type is a hard error (:class:ConfigError) naming the key, the offending value, and the accepted type -- invalid config must never be silently replaced with a default.

#_git_head

python
def _git_head() -> str | None

Get the current HEAD commit hash.

#filter_exempt_commits

python
def filter_exempt_commits(commits: list[str]) -> tuple[list[str], ExemptionStats]

Filter out commits exempt from changelog coverage.

Checks each commit against two predicates in canonical order:

  1. is_autogenerated (commit has Autogenerated: true trailer)
  2. is_changelog_only_commit (commit only touches changelog files)

First predicate match wins for stats counting: a commit that is both autogenerated and changelog-only is counted as autogenerated only.

Returns a tuple of (non-exempt commits, stats with per-predicate counts).

#_cache_path

python
def _cache_path(changes_dir: str) -> str

Return path to the .validated cache file.

#_read_cache

python
def _read_cache(changes_dir: str) -> str | None

Read the .validated file. Return the cached HEAD hash or None.

#_write_cache

python
def _write_cache(changes_dir: str) -> None

Write the current HEAD hash to the .validated cache file.

#_is_cache_valid

python
def _is_cache_valid(changes_dir: str) -> bool

Check if the validation cache is still valid.

Valid when:

  • .validated exists and contains a 40-char SHA
  • That SHA is an ancestor of (or equal to) HEAD
  • unreleased.jsonl's mtime is older than .validated's mtime

The one place in rlsbl where an unanswerable ancestry question is NOT a hard error: this is a cache, and the safe answer to "I cannot tell whether the cached commit is still on this branch" is to re-run the validation, not to refuse. Nothing is skipped and nothing is trusted -- the cost is one recomputation.

#check_hashes_resolve

python
def check_hashes_resolve(entries: list[ChangelogEntry]) -> tuple[bool, list[str]]

Check that every hash in every entry resolves via git rev-parse.

#_foreign_owner_description

python
def _foreign_owner_description(sha: str, scope) -> str

Who owns the files of a commit the asking scope does not claim.

Named for a message, so it answers in the workspace's own vocabulary: a member (with the releasable it belongs to), or a releasable's own state directory, which belongs to no member at all. Returns a short phrase, or a plain statement when nothing in the workspace claims the files.

#_out_of_scope_detail

python
def _out_of_scope_detail(raw: str, sha: str, entry: ChangelogEntry, scope) -> str

The finding for an entry naming a commit this changelog does not cover.

Deliberately NOT the out-of-range sentence: this commit IS between this checkout's nearest release commit and HEAD. What is wrong is where the entry was filed, and the remedy is to move it, not to repair a hash.

Two remedies, because there are two cases. When another member owns the commit's files the entry belongs in that member's changelog, so it is removed here and added there. When NOTHING owns them -- every path the commit touches is tool-owned, rlsbl's own bookkeeping -- there is no owning member's directory to add it from, and telling the reader to go to one names a place that does not exist. Such a commit needs no changelog coverage anywhere, so the entry is simply removed.

#check_in_range

python
def check_in_range(entries: list[ChangelogEntry], releases_dir: str, tag_glob: str | None=None, scope=None) -> tuple[bool, list[str]]

Check that every resolved hash is in the unreleased range.

Unreleased range: the commits since the release the release record binds this checkout to -- the highest archived version whose candidate_sha this history contains -- or all commits when the release record records none. releases_dir is that release record; tag_glob names the tag scheme, used to detect a tag that disagrees with a release commit.

When scope is set (monorepo mode), only commits touching files owned by the scope's members are considered in-range. scope is an :class:~rlsbl.ownership.OwnershipScope.

A hash outside that set fails for one of two DIFFERENT reasons, and they get different messages. Out of range means the commit is not between this checkout's nearest release commit and HEAD -- a stale hash, or work that already shipped. Out of scope means it is in the range and belongs to another releasable's territory: a cross-filed entry, which used to be reported as out of range and sent its reader hunting for a rewrite that never happened.

#check_coverage

python
def check_coverage(entries: list[ChangelogEntry], releases_dir: str, tag_glob: str | None=None, scope=None) -> tuple[bool, list[str]]

Check that every unreleased commit appears in at least one entry.

The unreleased range comes from the RELEASE RECORD at releases_dir -- see :func:check_in_range. Commits with the Autogenerated: true trailer are automatically exempted -- they are release infrastructure and don't need coverage. When scope is set (monorepo mode), only commits touching files the scope's members own require coverage -- a commit whose every file belongs to some other member is filtered out. scope is an :class:~rlsbl.ownership.OwnershipScope.

#_removal_remedy

python
def _removal_remedy(entry: ChangelogEntry, stale_hashes: list[str]) -> str

The changelog remove invocation that deletes entry.

Printed verbatim into a finding, so it has to be runnable as written. The entry's ULID id addresses it exactly and survives every unrelated edit to the file, so it is preferred. A legacy line written before ids existed carries none and is addressed by a commit it names instead -- which changelog remove accepts even when git cannot resolve that commit, since an unresolvable hash is the very condition this remedy is printed under.

#check_no_orphans

python
def check_no_orphans(entries: list[ChangelogEntry], releases_dir: str, tag_glob: str | None=None, scope=None) -> tuple[bool, list[str]]

Flag entries where all commits are stale (unresolvable or out of range).

An entry is fully orphaned when ALL its hashes are unresolvable. An entry is effectively orphaned when every hash is either unresolvable or outside the unreleased range (the RELEASE RECORD's range at releases_dir — see :func:check_in_range) — no commit is both valid and in range. scope is an :class:~rlsbl.ownership.OwnershipScope.

The two ways a hash can be outside the scoped range are counted apart and named apart, exactly as :func:check_in_range names them: a commit another releasable owns is out of scope, and calling it out of range would say the history no longer contains it, which is false.

#check_schema

python
def check_schema(entries: list[ChangelogEntry]) -> tuple[bool, list[str]]

Check that every entry passes schema validation.

#check_has_user_facing

python
def check_has_user_facing(entries: list[ChangelogEntry]) -> tuple[bool, list[str]]

Check that at least one entry is user-facing.

#check_batch_size_commits

python
def check_batch_size_commits(entries: list[ChangelogEntry], config: dict, version: str='unreleased') -> tuple[bool, list[str]]

Check that no entry has more commits than max_commits_per_entry.

config is the resolved batch_limits config (see :func:_get_batch_limits_config). Per-entry exclusions (matched by version + 1-based line number) silence the check for those entries.

#check_batch_size_entries

python
def check_batch_size_entries(entries_by_version: dict[str, list[ChangelogEntry]], config: dict) -> tuple[bool, list[str]]

Check that no commit appears in more than max_entries_per_commit entries.

entries_by_version maps version label (e.g. "unreleased" or "0.32.0") to its entry list, so the check spans across ALL JSONL files, not just unreleased. Per-commit exclusions in config silence specific hashes.

#_read_all_versioned_entries

python
def _read_all_versioned_entries(changes_dir: str) -> dict[str, list[ChangelogEntry]]

Read entries from unreleased.jsonl AND every x.y.z.jsonl in changes_dir.

Returns a mapping {version_label: entries} where version_label is "unreleased" or the bare semver string (e.g. "0.32.0"). Files that fail to parse are skipped silently -- other checks surface such errors separately.

#validate_unreleased

python
def validate_unreleased(changes_dir: str, tag_glob: str | None=None, scope=None, *, config: dict, bump_type: str | None=None) -> dict

Run all 8 validation checks on unreleased.jsonl.

Returns a dict with:

  • check names as keys, (passed, details) tuples as values
  • "passed": overall bool (True only if all checks pass)

Uses validation cache: if the cache is valid and HEAD hasn't changed, skips full revalidation. The unreleased range comes from the RELEASE RECORD -- the release archives beside changes_dir -- and tag_glob names the tag scheme those archives are checked against (e.g. mylib@v* or go/v*). When scope is set (monorepo mode), coverage and range checks only consider commits touching files the scope's members own. scope is an :class:~rlsbl.ownership.OwnershipScope.

The two batch_limits checks (batch_size_commits and batch_size_entries) read configuration via :func:_get_batch_limits_config from the provided config dict. The cross-version batch_size_entries check reads every x.y.z.jsonl file in changes_dir so it can detect commits that appear in too many entries across versions.

More tools from this site

  • claudestream Drive Claude Code from Python: run it as a subprocess and read its output as typed events, with async and sync sessions, sandbox policies, and tools you define in Python
  • claudewheel A TUI Claude Code Launcher that lets you have more than one profile, manage sessions lifecycle, pick the exact CC version, model to use (even older unlisted ones), pick which GitHub account to use, etc.
  • dirstat Fast, single-binary directory statistics CLI: every file under a tree grouped by format, with counts, sizes, and lines of code, as a colored terminal table or as JSON
  • fastware A batteries-included ASGI framework: msgspec JSON, a managed Granian server, dependency injection, SSE, WebSockets, auth, and a test client
  • go-toml-edit Zero-dep TOML editing library for Go with comment preservation
  • howmuchleft The fastest Claude Code statusline: context window, 5-hour, and weekly limit usage as three customizable gradient bars, rendering in about 6 ms
  • orxtra
  • pgdesign
  • predraw Declarative rendering pipeline: describe a scene in JSON and get SVG, PNG and WebP out, with light and dark style tokens, reusable components and text converted to path outlines
  • reposummary Turn a git repository's history into a Markdown journal: pick a time window or revision range and get a readable digest of what changed, optionally narrated by an LLM
  • safegit git wrapper CLI that gives each commit its own temporary index and retries ref updates on conflict, so concurrent agents share one repository
  • saferm Command-line replacement for rm that archives every deletion with a mandatory reason and the context it ran in, so deleted files can be listed, inspected and restored
  • selfdoc Static Site Generator that builds a project's documentation site directly from its source code, so the docs can never drift from the code they describe, with SEO/AEO, first-class blog, search, and cross-project linking built in
  • strictcli
  • stricttest An always-on test-isolation floor: a pytest plugin and a Go env-hygiene module that make a test suite structurally unable to reach real credentials, the real HOME, the network, or the development repository.
  • wesktop A Python framework that turns an ASGI web app into a desktop application, serving it from a local Granian server and displaying it in a native OS window via pywebview
Search