diff --git a/.claude/commands/bump-version.md b/.claude/commands/bump-version.md
index eb4eca863..511815971 100644
--- a/.claude/commands/bump-version.md
+++ b/.claude/commands/bump-version.md
@@ -38,43 +38,37 @@ Files that need updating:
- Read `diff_diff/__init__.py` and extract the current `__version__` value
- Store as `OLD_VERSION` for comparison link generation
-3. **Check CHANGELOG entry and resolve `RELEASE_DATE`**:
- - Search `CHANGELOG.md` for `## [NEW_VERSION]` section header.
- - If found with content (at least one `### Added/Changed/Fixed` subsection with
- bullet points):
- - **Parse the existing header date** (e.g., `## [3.1.3] - 2026-04-19` → `2026-04-19`).
- Store as `RELEASE_DATE` and skip to step 5.
- - If the header has no date (malformed), abort with: `Error: CHANGELOG header for
- [NEW_VERSION] is missing a date. Fix the header before re-running.`
- - If not found or empty: Set `RELEASE_DATE` to today's date in `YYYY-MM-DD` format,
- then continue to step 4.
+3. **Compile the changelog and resolve `RELEASE_DATE`** (release notes come
+ exclusively from `changelog.d/` fragments; the old git-log generation step
+ is removed):
+
+ ```bash
+ python3 .claude/scripts/changelog_compile.py compile --version NEW_VERSION --date "$(date +%F)"
+ ```
+
+ Key off the exit code:
+ - **Exit 0** (compiled): the `## [NEW_VERSION]` section and comparison link
+ were written and the fragments deleted. `RELEASE_DATE` = today (the
+ `--date` you passed).
+ - **Exit 4** (already compiled): the section already exists with content and
+ no fragments remain — an idempotent re-run after a partial bump. The
+ compiler prints the existing header's date; use THAT as `RELEASE_DATE`.
+ On such a re-run, verify EACH version file in the table individually
+ (step 5's blind `OLD_VERSION → NEW_VERSION` replacement no-ops once
+ `diff_diff/__init__.py` is already bumped, so grep every file rather than
+ trusting the replacements).
+ - **Any other exit**: surface the compiler's message and stop. For a
+ legitimately fragment-free cycle (all merged PRs CI/tooling-only), write
+ a minimal `### Internal` stub fragment describing the release, **commit
+ it**, then re-run (an uncommitted fragment is rejected by the compiler's
+ dirty-fragment guard — deliberately, so nothing unreviewed is swept into
+ a release and deleted).
`RELEASE_DATE` is the single source of truth for the release date across every file
touched in this bump. Do not recompute it downstream.
-4. **Generate CHANGELOG from git** (only if needed):
- - Run: `git log v{OLD_VERSION}..HEAD --oneline`
- - If no tag exists, use: `git log --oneline -50`
- - Categorize commits using these heuristics:
- - **Added**: commits containing "add", "new", "implement", "introduce", "create"
- - **Changed**: commits containing "update", "change", "improve", "optimize", "refactor", "enhance"
- - **Fixed**: commits containing "fix", "bug", "correct", "repair", "resolve"
- - Use the `RELEASE_DATE` resolved in step 3 for the header.
- - Create CHANGELOG entry in this format:
- ```markdown
- ## [X.Y.Z] - YYYY-MM-DD
-
- ### Added
- - Feature description from commit message
-
- ### Changed
- - Change description from commit message
-
- ### Fixed
- - Fix description from commit message
- ```
- - Only include sections that have commits (omit empty sections)
- - Insert the new entry after the changelog header (after the "adheres to Semantic Versioning" line)
+4. *(Removed — the compiler owns changelog generation; the fragment format is
+ documented in `changelog.d/README.md`.)*
5. **Update version in all files**:
Use the Edit tool to update each file:
@@ -99,14 +93,10 @@ Files that need updating:
preserve the quoting style. `RELEASE_DATE` must match the CHANGELOG header
date; never substitute a freshly computed "today" value here.
-6. **Update CHANGELOG comparison links**:
- - Run `git remote get-url origin` to determine the repository's GitHub URL
- (strip `.git` suffix, convert SSH format to HTTPS if needed)
- - At the bottom of `CHANGELOG.md`, after `[OLD_VERSION]:`, add the new comparison link:
- ```
- [NEW_VERSION]: https://github.com/OWNER/REPO/compare/vOLD_VERSION...vNEW_VERSION
- ```
- using the owner/repo derived from the remote URL.
+6. **CHANGELOG comparison link** — written by the compiler in step 3 (format
+ `[NEW]: /compare/vOLD...vNEW`, both versions `v`-prefixed, inserted
+ immediately above the previous version's link line; the base URL is taken
+ from that line, not from the git remote). Nothing to do manually.
7. **Report summary**:
Display a summary of all changes made:
@@ -119,7 +109,7 @@ Files that need updating:
- rust/Cargo.toml: version = "NEW_VERSION"
- diff_diff/guides/llms-full.txt: Version: NEW_VERSION
- CITATION.cff: version: NEW_VERSION, date-released: YYYY-MM-DD
- - CHANGELOG.md: Added/verified [NEW_VERSION] entry
+ - CHANGELOG.md: compiled [NEW_VERSION] from changelog.d/
Next steps:
1. Review changes: git diff
@@ -131,12 +121,13 @@ Files that need updating:
## Notes
- The Rust version in `rust/Cargo.toml` is always synced to match the Python version
-- If CHANGELOG already has the target version entry with content, it will not be overwritten
-- Commit messages are cleaned up (prefixes like "feat:", "fix:" are removed) for CHANGELOG
-- The comparison link format uses `v` prefix for tags (e.g., `v2.2.0`)
+- If CHANGELOG already has the target version section (and `changelog.d/` is empty),
+ the compiler exits 4 and the bump proceeds as a re-run — the existing section is
+ never overwritten
+- Release notes come from curated `changelog.d/` fragments; commit messages are never read
- `CITATION.cff` `date-released` and the `CHANGELOG.md` section header share a single
- `RELEASE_DATE` resolved in step 3: if the CHANGELOG entry was pre-populated, its
- existing header date wins (so pre-written changelog drafts don't silently drift
+ `RELEASE_DATE` resolved in step 3: an already-compiled header's date wins via the
+ compiler's exit-4 path (so a re-run after a partial bump doesn't silently drift
from the CITATION date); otherwise today's date is used for both. If the release
is cut on a different day than the bump, update both surfaces manually — drift
causes auto-citation tools (Zenodo, GitHub's "cite this repository", reference
diff --git a/.claude/commands/docs-impact.md b/.claude/commands/docs-impact.md
index 6c8fa5faa..8a9333808 100644
--- a/.claude/commands/docs-impact.md
+++ b/.claude/commands/docs-impact.md
@@ -109,7 +109,7 @@ LOW DRIFT RISK:
No map entry:
Stale references:
-Always check: CHANGELOG.md, ROADMAP.md
+Always check: changelog.d/ fragment (never CHANGELOG.md's Unreleased directly), ROADMAP.md
```
### 7. Flag Missing Entries
diff --git a/.claude/commands/pre-merge-check.md b/.claude/commands/pre-merge-check.md
index b24b83bb4..7eba49d40 100644
--- a/.claude/commands/pre-merge-check.md
+++ b/.claude/commands/pre-merge-check.md
@@ -58,8 +58,13 @@ Run it:
```bash
SCRATCH="$(git rev-parse --git-path premerge-scan)"; mkdir -p "$SCRATCH"
python3 .claude/scripts/premerge_scan.py --scratch "$SCRATCH"
+python3 .claude/scripts/changelog_compile.py check
```
+The second command is the changelog-fragment guard (pointer-only
+`## [Unreleased]` + fragment grammar) - it catches a direct Unreleased edit
+here instead of first failing in the label-gated docs-tests CI lane.
+
- If it **exits 4**, a git or file-read operation failed — the scan is **incomplete**
and its run-lists were truncated to empty. **Stop and report the error;** do NOT
continue (empty run-lists would silently run no tests and misstate coverage).
@@ -150,6 +155,10 @@ Based on your changes to:
- [ ] Happy path tested
- [ ] Edge cases tested (empty data, NaN inputs, boundary conditions)
- [ ] Error/warning paths tested with behavioral assertions
+
+### Changelog
+- [ ] User-visible change => changelog.d/YYYYMMDD-.md fragment present
+ (never edit CHANGELOG.md's [Unreleased] directly - CI-enforced)
```
#### If Methodology Files Changed
diff --git a/.claude/commands/push-pr-update.md b/.claude/commands/push-pr-update.md
index e2a53e8a2..750665632 100644
--- a/.claude/commands/push-pr-update.md
+++ b/.claude/commands/push-pr-update.md
@@ -165,6 +165,8 @@ When the working tree is clean but commits are ahead, check for methodology issu
[HIGH] docs/survey-roadmap.md
Run /docs-impact for full details.
```
+ Also warn when the changes touch `diff_diff/` but the branch carries no
+ `changelog.d/` fragment (see CONTRIBUTING.md "Changelog fragments").
This is a WARNING, not a blocker.
Note: Section 3b checks are informational warnings only — no AskUserQuestion prompt, since changes are already committed and cannot be unstaged. This differs from the staged-changes path (Section 3) which offers a "fix vs continue" choice.
@@ -206,6 +208,8 @@ Note: Section 3b checks are informational warnings only — no AskUserQuestion p
[METHODOLOGY] docs/methodology/REGISTRY.md —
Run /docs-impact for full details.
```
+ Also warn when the changes touch `diff_diff/` but the branch carries no
+ `changelog.d/` fragment (see CONTRIBUTING.md "Changelog fragments").
This is a WARNING, not a blocker.
3. **Capture file count for reporting**:
diff --git a/.claude/commands/submit-pr.md b/.claude/commands/submit-pr.md
index ef52fd5b8..07b157249 100644
--- a/.claude/commands/submit-pr.md
+++ b/.claude/commands/submit-pr.md
@@ -374,8 +374,19 @@ Fill in the template:
## Security / privacy
- Confirm no secrets/PII in this PR: Yes
+
+## Changelog
+- changelog.d/ fragment added (or N/A - no user-visible change):
```
+The Changelog line mirrors `.github/pull_request_template.md` (this embedded
+copy is what `gh pr create` actually submits - GitHub never applies the
+template file on CLI-created PRs; keep the two in sync). While filling it in,
+check the branch diff: if it touches `diff_diff/` and contains no
+`changelog.d/` file, answer honestly and add a warning line to the step-11
+report (a missing fragment is a WARNING, not a blocker - see CONTRIBUTING.md
+"Changelog fragments").
+
Do not add an authorship footer to the PR body.
**Template logic:**
diff --git a/.claude/scripts/changelog_compile.py b/.claude/scripts/changelog_compile.py
new file mode 100644
index 000000000..48a3d4700
--- /dev/null
+++ b/.claude/scripts/changelog_compile.py
@@ -0,0 +1,514 @@
+#!/usr/bin/env python3
+"""Changelog-fragment validator and release-time compiler.
+
+PRs write per-PR entry files under ``changelog.d/`` instead of editing
+``CHANGELOG.md``'s ``## [Unreleased]`` section (which stays pointer-only
+between releases; the invariant is CI-enforced by
+``tests/test_changelog_fragments.py``). At release time ``compile`` merges
+the fragments into a new ``## [X.Y.Z] - DATE`` section and deletes them.
+
+Subcommands
+-----------
+check
+ Validate repo state: ``changelog.d/README.md`` exists; the directory
+ holds only README.md, valid fragments, and dotfiles; every fragment
+ parses under the body grammar; CHANGELOG.md's Unreleased section is
+ pointer-only. Exit 0 clean, exit 1 with findings otherwise.
+compile --version X.Y.Z --date YYYY-MM-DD [--allow-dirty]
+ Assemble the release section. Exit 0 = compiled; exit 4 = the target
+ header already exists with no fragments left (idempotent re-run; the
+ header's date is printed for RELEASE_DATE reuse); anything else is an
+ error (exit 2 usage / exit 1 state).
+
+Stdlib-only, Python 3.9-compatible (annotations quoted), no dependence on
+cwd: the repo root defaults to this file's grandparent directory and can be
+overridden with ``--root`` (used by the tmp-dir tests).
+"""
+
+import argparse
+import datetime
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+# Single source of the category vocabulary AND the compile-time section
+# order. changelog.d/README.md mirrors this list; the mirror is pinned by
+# tests/test_changelog_fragments.py (README<->compiler parity test).
+CATEGORIES = (
+ "Added",
+ "Changed",
+ "Deprecated",
+ "Removed",
+ "Fixed",
+ "Security",
+ "Performance",
+ "Documentation",
+ "Testing",
+ "Breaking Changes",
+ "Behavioral Changes",
+ "Internal",
+)
+
+POINTER_COMMENT = (
+ ""
+)
+
+FRAGMENT_NAME_RE = re.compile(r"^(\d{8})-[a-z0-9][a-z0-9-]*\.md$")
+VERSION_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
+
+EXIT_OK = 0
+EXIT_FINDINGS = 1
+EXIT_USAGE = 2
+EXIT_ALREADY_COMPILED = 4
+
+
+def default_root():
+ # .claude/scripts/changelog_compile.py -> repo root is parents[2].
+ return Path(__file__).resolve().parents[2]
+
+
+def _parse_fragment(text):
+ """Return (blocks, errors). blocks = list of (category, body_lines)
+ where body_lines excludes the header line and preserves bytes."""
+ errors = []
+ blocks = []
+ current = None # (category, [lines])
+ for lineno, line in enumerate(text.splitlines(), 1):
+ if line.startswith("## "):
+ errors.append(f"line {lineno}: '## ' header not allowed in a fragment")
+ continue
+ if line.startswith("### "):
+ cat = line[4:].strip()
+ if cat not in CATEGORIES:
+ errors.append(
+ f"line {lineno}: unknown category {cat!r} "
+ f"(allowed: {', '.join(CATEGORIES)})"
+ )
+ current = (cat, [])
+ blocks.append(current)
+ continue
+ if not line.strip():
+ if current is not None:
+ current[1].append(line)
+ continue
+ if current is None:
+ errors.append(f"line {lineno}: content outside any '### ' block")
+ continue
+ if line.startswith("- ") and not line[2:].strip():
+ errors.append(f"line {lineno}: empty top-level bullet ('- ' with no content)")
+ continue
+ if line.startswith("- ") or line[0] in (" ", "\t"):
+ current[1].append(line)
+ else:
+ errors.append(
+ f"line {lineno}: not a top-level bullet ('- ') or indented " "continuation line"
+ )
+ if not blocks:
+ errors.append("no '### ' block found")
+ for cat, lines in blocks:
+ if not any(ln.startswith("- ") and ln[2:].strip() for ln in lines):
+ errors.append(f"category {cat!r}: no top-level bullet")
+ return blocks, errors
+
+
+def _valid_fragment_name(name):
+ m = FRAGMENT_NAME_RE.match(name)
+ if not m:
+ return False
+ raw = m.group(1)
+ try:
+ datetime.date(int(raw[0:4]), int(raw[4:6]), int(raw[6:8]))
+ except ValueError:
+ return False
+ return True
+
+
+_UNRELEASED_HEADER_RE = re.compile(r"^## \[Unreleased\]\s*$", flags=re.MULTILINE)
+
+
+def _unreleased_headers(changelog_text):
+ """All '## [Unreleased]' header matches, in file order."""
+ return list(_UNRELEASED_HEADER_RE.finditer(changelog_text))
+
+
+def _unreleased_slice(changelog_text):
+ """Return (start, end) character offsets of the FIRST Unreleased section
+ body (after the header line, up to the next '## ' line), or None.
+
+ Callers must separately enforce that exactly one Unreleased header
+ exists (run_check does) — a duplicate header would otherwise hide its
+ body from this slice.
+ """
+ headers = _unreleased_headers(changelog_text)
+ if not headers:
+ return None
+ m = headers[0]
+ nl = changelog_text.find("\n", m.start())
+ if nl == -1:
+ # Header is the last line with no trailing newline: empty body.
+ return len(changelog_text), len(changelog_text)
+ body_start = nl + 1
+ nxt = re.compile(r"^## ", flags=re.MULTILINE).search(changelog_text, body_start)
+ body_end = nxt.start() if nxt else len(changelog_text)
+ return body_start, body_end
+
+
+def run_check(root):
+ findings = []
+ frag_dir = root / "changelog.d"
+ readme = frag_dir / "README.md"
+ fragments = []
+ if not frag_dir.is_dir():
+ findings.append("changelog.d/ directory is missing")
+ else:
+ if not readme.is_file():
+ findings.append("changelog.d/README.md (the format spec) is missing")
+ for p in sorted(frag_dir.iterdir()):
+ if p.name.startswith("."):
+ continue # dotfiles (.DS_Store etc.) are ignored
+ if p.name == "README.md":
+ continue
+ if p.is_dir():
+ findings.append(f"changelog.d/{p.name}: subdirectories are not allowed")
+ continue
+ if not _valid_fragment_name(p.name):
+ findings.append(
+ f"changelog.d/{p.name}: name must match "
+ "YYYYMMDD-.md with a real calendar date"
+ )
+ continue
+ # lstat-level guard BEFORE any read: a symlink's content is
+ # mutable out-of-band, and a FIFO/device would hang or crash the
+ # read; neither can be certified.
+ if p.is_symlink() or not p.is_file():
+ findings.append(f"changelog.d/{p.name}: not a regular file (symlink or special)")
+ continue
+ try:
+ text = p.read_text()
+ except (OSError, UnicodeDecodeError) as exc:
+ findings.append(f"changelog.d/{p.name}: unreadable ({exc})")
+ continue
+ fragments.append(p)
+ _, errors = _parse_fragment(text)
+ findings.extend(f"changelog.d/{p.name}: {e}" for e in errors)
+
+ changelog = root / "CHANGELOG.md"
+ if not changelog.is_file():
+ findings.append("CHANGELOG.md is missing")
+ else:
+ changelog_text = changelog.read_text()
+ headers = _unreleased_headers(changelog_text)
+ if not headers:
+ findings.append("CHANGELOG.md: '## [Unreleased]' header is missing")
+ elif len(headers) > 1:
+ findings.append(
+ f"CHANGELOG.md: {len(headers)} '## [Unreleased]' headers found "
+ "— exactly one is allowed (a duplicate section would hide "
+ "direct edits from the pointer-only guard)"
+ )
+ else:
+ sl = _unreleased_slice(changelog_text)
+ assert sl is not None
+ body = changelog_text[sl[0] : sl[1]]
+ nonblank = [ln for ln in body.splitlines() if ln.strip()]
+ if nonblank != [POINTER_COMMENT]:
+ findings.append(
+ "CHANGELOG.md: the Unreleased section must contain exactly "
+ "the pointer comment (write entries as changelog.d/ "
+ "fragments instead):\n " + POINTER_COMMENT
+ )
+ return findings, fragments
+
+
+def _semver_tuple(s):
+ return tuple(int(x) for x in s.split("."))
+
+
+def _existing_headers(changelog_text):
+ """[(version, date_or_None, match_start)] in file order."""
+ out = []
+ for m in re.finditer(
+ r"^## \[(\d+\.\d+\.\d+)\](?: - (\S+))?\s*$", changelog_text, flags=re.MULTILINE
+ ):
+ out.append((m.group(1), m.group(2), m.start()))
+ return out
+
+
+def _section_nonempty(changelog_text, header_start):
+ nl = changelog_text.find("\n", header_start)
+ if nl == -1:
+ # Header is the last line with no trailing newline: empty section.
+ return False
+ body_start = nl + 1
+ nxt = re.compile(r"^## ", flags=re.MULTILINE).search(changelog_text, body_start)
+ body = changelog_text[body_start : nxt.start() if nxt else len(changelog_text)]
+ has_cat = any(
+ ln.startswith("### ") and ln[4:].strip() in CATEGORIES for ln in body.splitlines()
+ )
+ has_bullet = any(ln.startswith("- ") for ln in body.splitlines())
+ return has_cat and has_bullet
+
+
+def _canonical_date(s):
+ """True iff s is exactly YYYY-MM-DD for a real calendar date (rejects
+ 3.11+/3.14 fromisoformat leniency toward compact/partial spellings)."""
+ try:
+ parsed = datetime.date.fromisoformat(s)
+ except ValueError:
+ return False
+ return parsed.isoformat() == s
+
+
+def run_compile(root, version, date_s, allow_dirty):
+ if not VERSION_RE.match(version):
+ print(
+ f"error: --version {version!r} is not a canonical SemVer " "X.Y.Z (no leading zeros)",
+ file=sys.stderr,
+ )
+ return EXIT_USAGE
+ if not _canonical_date(date_s):
+ print(
+ f"error: --date {date_s!r} is not a canonical YYYY-MM-DD date",
+ file=sys.stderr,
+ )
+ return EXIT_USAGE
+
+ findings, fragments = run_check(root)
+ if findings:
+ for f in findings:
+ print(f"check: {f}", file=sys.stderr)
+ return EXIT_FINDINGS
+
+ changelog_path = root / "CHANGELOG.md"
+ text = changelog_path.read_text()
+ headers = _existing_headers(text)
+ versions = [h[0] for h in headers]
+ duplicated = sorted({v for v in versions if versions.count(v) > 1})
+ if duplicated:
+ # A duplicated release header is a corrupt changelog whichever
+ # version it is: the loop below inspects only the first match, so
+ # exit 4 could otherwise certify a file that still carries a second
+ # '## [X.Y.Z]' section.
+ print(
+ "error: duplicated release header(s) in CHANGELOG.md: "
+ + ", ".join(f"'## [{v}]'" for v in duplicated)
+ + " — fix the file before compiling",
+ file=sys.stderr,
+ )
+ return EXIT_FINDINGS
+ prev = max(versions, key=_semver_tuple) if versions else None
+
+ # Existing-header detection FIRST, so the idempotent re-run path is
+ # reachable before any monotonicity check.
+ for hv, hdate, hstart in headers:
+ if hv == version:
+ if prev != version:
+ print(
+ f"error: '## [{version}]' exists but is older than the "
+ f"latest release {prev} — refusing a downgrade re-run",
+ file=sys.stderr,
+ )
+ return EXIT_FINDINGS
+ if not _section_nonempty(text, hstart):
+ print(
+ f"error: '## [{version}]' exists but its section is "
+ "empty — not a completed compile; fix the header",
+ file=sys.stderr,
+ )
+ return EXIT_FINDINGS
+ if fragments:
+ print(
+ f"error: '## [{version}]' already exists but "
+ f"{len(fragments)} fragment(s) remain in changelog.d/ — "
+ "partial or conflicting state; resolve manually",
+ file=sys.stderr,
+ )
+ return EXIT_FINDINGS
+ if hdate is None or not _canonical_date(hdate):
+ print(
+ f"error: '## [{version}]' has a missing or non-canonical "
+ f"date ({hdate!r}); fix the header before reusing it",
+ file=sys.stderr,
+ )
+ return EXIT_FINDINGS
+ below = [v for v in versions if _semver_tuple(v) < _semver_tuple(version)]
+ predecessor = max(below, key=_semver_tuple) if below else None
+ if predecessor is None:
+ # compile always anchors its comparison link to an existing
+ # release, so a sole-header state cannot be its output.
+ print(
+ f"error: '## [{version}]' is the only release header — "
+ "not a completed compile (no preceding release to anchor "
+ "the comparison link); fix CHANGELOG.md",
+ file=sys.stderr,
+ )
+ return EXIT_FINDINGS
+ if predecessor is not None:
+ link_re = re.compile(
+ r"^\["
+ + re.escape(version)
+ + r"\]: \S+/compare/v"
+ + re.escape(predecessor)
+ + r"\.\.\.v"
+ + re.escape(version)
+ + r"$",
+ flags=re.MULTILINE,
+ )
+ if not link_re.search(text):
+ print(
+ f"error: '## [{version}]' exists but its comparison "
+ f"link '[{version}]: .../compare/v{predecessor}...v"
+ f"{version}' is missing or wrongly sourced — not a "
+ "completed compile; fix the link block",
+ file=sys.stderr,
+ )
+ return EXIT_FINDINGS
+ print(f"already-compiled: version={version} date={hdate}")
+ return EXIT_ALREADY_COMPILED
+
+ if prev is None:
+ print(
+ "error: CHANGELOG.md has no existing '## [X.Y.Z]' release header "
+ "to anchor the comparison link",
+ file=sys.stderr,
+ )
+ return EXIT_FINDINGS
+ if _semver_tuple(version) <= _semver_tuple(prev):
+ print(
+ f"error: --version {version} must exceed the latest release " f"{prev}",
+ file=sys.stderr,
+ )
+ return EXIT_FINDINGS
+ if not fragments:
+ print(
+ "error: changelog.d/ has no fragments — nothing to release "
+ "(releases require fragments; for a fragment-free cycle write a "
+ "minimal '### Internal' stub fragment)",
+ file=sys.stderr,
+ )
+ return EXIT_FINDINGS
+
+ if not allow_dirty:
+ # Committed-content guard: every fragment about to be compiled and
+ # DELETED must be a regular file whose bytes match its HEAD blob.
+ # This is deliberately not a `git status` parse — status output
+ # depends on configuration (status.showUntrackedFiles=no, ignore
+ # rules via .git/info/exclude or a global gitignore) and does not
+ # see through symlinks, all of which could let unreviewed content
+ # be swept into the release and destroyed.
+ if not (root / ".git").exists():
+ print(
+ "error: no .git at the resolved root — cannot verify the "
+ "fragments are committed; pass --allow-dirty for scratch "
+ "copies",
+ file=sys.stderr,
+ )
+ return EXIT_FINDINGS
+ problems = []
+ for p in fragments:
+ rel = f"changelog.d/{p.name}"
+ if p.is_symlink() or not p.is_file():
+ problems.append(f"{rel}: not a regular file (symlink or special)")
+ continue
+ tree_res = subprocess.run(
+ ["git", "-C", str(root), "ls-tree", "HEAD", "--", rel],
+ capture_output=True,
+ text=True,
+ )
+ tree = tree_res.stdout.strip()
+ if tree_res.returncode != 0 or not tree:
+ # Missing from HEAD — including an unborn HEAD (init with no
+ # commits), where ls-tree itself fails.
+ problems.append(f"{rel}: not committed in HEAD")
+ continue
+ mode, _, sha = tree.split()[0], tree.split()[1], tree.split()[2]
+ if mode not in ("100644", "100755"):
+ problems.append(f"{rel}: committed as a non-regular entry (mode {mode})")
+ continue
+ blob = subprocess.run(
+ ["git", "-C", str(root), "cat-file", "blob", sha],
+ capture_output=True,
+ check=True,
+ ).stdout
+ if blob != p.read_bytes():
+ problems.append(f"{rel}: worktree bytes differ from the HEAD blob")
+ if problems:
+ print(
+ "error: changelog.d/ fragments must be committed unchanged "
+ "before a release (they are compiled into CHANGELOG.md and "
+ "deleted):\n "
+ + "\n ".join(problems)
+ + "\ncommit them first, or pass --allow-dirty",
+ file=sys.stderr,
+ )
+ return EXIT_FINDINGS
+
+ # Assemble: category order = CATEGORIES; within a category, fragments in
+ # ascending filename order, block bodies preserved byte-for-byte with
+ # trailing blank lines stripped and contributions joined contiguously.
+ per_category = {}
+ for p in fragments:
+ blocks, _ = _parse_fragment(p.read_text())
+ for cat, lines in blocks:
+ body = "\n".join(lines).rstrip("\n")
+ per_category.setdefault(cat, []).append(body)
+ parts = []
+ for cat in CATEGORIES:
+ if cat in per_category:
+ parts.append(f"### {cat}\n" + "\n".join(per_category[cat]))
+ new_section = f"## [{version}] - {date_s}\n\n" + "\n\n".join(parts) + "\n\n"
+
+ sl = _unreleased_slice(text)
+ assert sl is not None # run_check guaranteed the header + pointer
+ insert_at = sl[1]
+ text = text[:insert_at] + new_section + text[insert_at:]
+
+ prev_link_re = re.compile(
+ r"^\[" + re.escape(prev) + r"\]: (\S+?)/compare/\S+$", flags=re.MULTILINE
+ )
+ m = prev_link_re.search(text)
+ if not m:
+ print(
+ f"error: comparison link '[{prev}]: .../compare/...' not found "
+ "at the bottom of CHANGELOG.md",
+ file=sys.stderr,
+ )
+ return EXIT_FINDINGS
+ base = m.group(1)
+ new_link = f"[{version}]: {base}/compare/v{prev}...v{version}\n"
+ text = text[: m.start()] + new_link + text[m.start() :]
+
+ changelog_path.write_text(text)
+ for p in fragments:
+ p.unlink()
+ print(f"compiled: version={version} date={date_s} fragments={len(fragments)}")
+ return EXIT_OK
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--root", type=Path, default=None, help="repo root override")
+ sub = parser.add_subparsers(dest="cmd", required=True)
+ sub.add_parser("check")
+ comp = sub.add_parser("compile")
+ comp.add_argument("--version", required=True)
+ comp.add_argument("--date", required=True)
+ comp.add_argument("--allow-dirty", action="store_true")
+ args = parser.parse_args(argv)
+
+ root = (args.root or default_root()).resolve()
+ if args.cmd == "check":
+ findings, _ = run_check(root)
+ if findings:
+ for f in findings:
+ print(f"check: {f}", file=sys.stderr)
+ return EXIT_FINDINGS
+ print("check: OK")
+ return EXIT_OK
+ return run_compile(root, args.version, args.date, args.allow_dirty)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.github/codex/prompts/pr_review.md b/.github/codex/prompts/pr_review.md
index 1ee3a4443..90c843711 100644
--- a/.github/codex/prompts/pr_review.md
+++ b/.github/codex/prompts/pr_review.md
@@ -22,6 +22,13 @@ SECONDARY PRIORITIES (in order):
6) Minimization of tech debt
7) Security (including accidental secrets)
8) Documentation + tests
+ - A diff that CHANGES USER-VISIBLE BEHAVIOR but carries no `changelog.d/`
+ fragment is a P2 (release notes live as per-PR fragment files - see
+ `changelog.d/README.md`). Comment-only, docs-only, or test-only diffs
+ never trigger this - no behavior changed, no fragment owed.
+ - A diff that adds bullets directly under CHANGELOG.md's `## [Unreleased]`
+ is a P3: point at the fragment convention (that section is pointer-only
+ and CI-enforced).
## Edge Case Review (learned from PR #97 analysis)
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 9f18006e5..625b4525c 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -9,3 +9,6 @@
## Security / privacy
- Confirm no secrets/PII in this PR:
+
+## Changelog
+- changelog.d/ fragment added (or N/A - no user-visible change):
diff --git a/.github/workflows/docs-tests.yml b/.github/workflows/docs-tests.yml
index a8fdbd6b0..9e736a102 100644
--- a/.github/workflows/docs-tests.yml
+++ b/.github/workflows/docs-tests.yml
@@ -11,6 +11,13 @@ on:
- 'tests/test_docs_ia.py'
- 'tests/test_v4_matrix.py'
- 'tests/test_naming_guard.py'
+ # Changelog-fragment guard: the fragment store, the compiler, and
+ # CHANGELOG.md itself trigger here (their ONLY CI lane - rust-test.yml
+ # deliberately does not fire on fragment-only diffs).
+ - 'tests/test_changelog_fragments.py'
+ - 'changelog.d/**'
+ - 'CHANGELOG.md'
+ - '.claude/scripts/changelog_compile.py'
# tests/conftest.py is auto-loaded by pytest for the snippet
# test run and mutates sys.path + MPLBACKEND (conftest.py:14, 18);
# changes there can break snippet exec without touching the test
@@ -34,6 +41,10 @@ on:
- 'tests/test_docs_ia.py'
- 'tests/test_v4_matrix.py'
- 'tests/test_naming_guard.py'
+ - 'tests/test_changelog_fragments.py'
+ - 'changelog.d/**'
+ - 'CHANGELOG.md'
+ - '.claude/scripts/changelog_compile.py'
- 'tests/conftest.py'
- 'pyproject.toml'
# sphinx-build job mirrors RTD setup; trigger when RTD config drifts
@@ -112,6 +123,12 @@ jobs:
# edits must not bypass it.
run: PYTHONPATH=. DIFF_DIFF_BACKEND=python pytest tests/test_naming_guard.py -v
+ - name: Run changelog-fragment guard
+ # Pointer-only [Unreleased] invariant + fragment grammar + compiler
+ # behavior + these very path filters (self-pinning; see
+ # tests/test_changelog_fragments.py::TestWorkflowPins).
+ run: PYTHONPATH=. DIFF_DIFF_BACKEND=python pytest tests/test_changelog_fragments.py -v
+
sphinx-build:
name: Sphinx HTML build (-W warnings as errors)
# Skip unrelated label churn: a non-ready-for-ci label add/remove won't run this job.
@@ -209,6 +226,12 @@ jobs:
# Python>=3.10) before it lands.
python-version: '3.9'
+ - name: Run changelog-fragment check on the floor Python
+ # The compiler promises 3.9 compatibility but its behavioral tests
+ # run in the 3.14 doc-snippets job; this stdlib-only invocation is
+ # the floor's continuous syntax + behavior smoke for the script.
+ run: python .claude/scripts/changelog_compile.py check
+
- name: Install docs dependencies
# Same pip install line as sphinx-build above. Just installs - does
# not run a Sphinx build (sphinx-build covers full rendering on 3.11).
diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml
index 5ca666a2e..00cdd3006 100644
--- a/.github/workflows/rust-test.yml
+++ b/.github/workflows/rust-test.yml
@@ -10,6 +10,8 @@ on:
# tests/test_doc_snippets.py is owned by docs-tests.yml; exclude it
# so a harness-only edit does not fan out into the Rust matrix.
- '!tests/test_doc_snippets.py'
+ # Same ownership split: the changelog-fragment guard runs in docs-tests.yml.
+ - '!tests/test_changelog_fragments.py'
- 'tools/**'
- 'pyproject.toml'
- '.github/workflows/rust-test.yml'
@@ -43,6 +45,8 @@ on:
- 'diff_diff/**'
- 'tests/**'
- '!tests/test_doc_snippets.py'
+ # Same ownership split: the changelog-fragment guard runs in docs-tests.yml.
+ - '!tests/test_changelog_fragments.py'
- 'tools/**'
- 'pyproject.toml'
- '.github/workflows/rust-test.yml'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c7ba25a59..f40bf5fac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,84 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
-### Added
-- **Chang (2020) §4.2.2 RCS simulation-DGP replication** (`tests/test_methodology_dml_did.py`,
- DML PR-B2): the paper's own kernel-design repeated-cross-section DGP as
- maintainer validation fixtures for `DMLDiD(panel=False)` — a DGP-shape pin
- (distributions, all three innovation scales, the design's built-in confounded
- contrast → θ₀+1, and both correct-specification facts), seed-pinned recovery at
- both paper sample sizes with a discriminating comparison against the unadjusted
- contrast, and a slow Monte Carlo coverage lane. The §4.2 parameterizations are
- extracted into the paper review; the §4.2.1 ML design is documented as not
- replicable with the bundled unpenalized learners (narrowed TODO row) and the
- REGISTRY carries the replication-scope Note.
-
-### Changed
-- **`n_bootstrap` type guards aligned onto `utils.validate_n_bootstrap`**
- for the estimators the M-081 sweep deliberately left out —
- `HeterogeneousAdoptionDiD`, `ChaisemartinDHaultfoeuille`, `TROP`,
- `SyntheticDiD` (jackknife lane included), plus the two HAD pretest
- helpers (`stute_test`, `stute_joint_pretest`): previously-accepted
- type-blind values now raise the shared message — `True` (silently ran as
- 1 replicate on HAD/dCDH), floats like `2.5` (passed the `>= 2` floors),
- and bool/negative under SyntheticDiD's jackknife floor exemption. The
- estimator-specific floors are unchanged and keep their own messages for
- non-negative sub-floor integers (TROP/SDiD `n_bootstrap=1`, HAD `0`);
- NEGATIVE values now surface the shared validator's message instead of
- each estimator's former wording.
-- **`honest_did` inference-df resolution consolidated** onto the shared
- `aggregation.resolve_inference_df()` (three duplicated precedence blocks
- removed). Same precedence; `HonestDiDResults.df_survey` is now
- float-typed (`31.0` where it was `31`), and a fractional `df_inference`
- is preserved instead of truncated.
-
-### Fixed
-- **Per-row event-study df provenance (M-092 completion)** for the four
- remaining holes — `EfficientDiD`, `ImputationDiD`, `ContinuousDiD`, and
- `HeterogeneousAdoptionDiD`: each results class gains a results-level
- `event_study_df` scalar (appended last; positional `__init__` indexes
- unchanged) threaded into the unified event-study container's per-row `df`
- column, which was all-NaN even on survey fits whose p-values were governed
- by a finite survey df. Finite on analytical survey fits (ImputationDiD:
- the final replicate-override df, level-matched on replicate replays, lead
- rows included; EfficientDiD: the post-overall snapshot; HAD: the
- unit-level design df); `None` — never the replicate-undefined `0`
- sentinel — on non-survey fits, on bootstrapped fits (percentile inference
- used no df, matching the shipped producer convention), and when no
- event-study surface was built. Inference values are unchanged everywhere.
-- **`ContinuousDiD` `survey_metadata` granularity unified across inference
- branches**: the bootstrap and degenerate no-post-cells arms now publish
- the same UNIT-level metadata as the analytical arm (the
- CS/EfficientDiD convention); previously they kept the obs-level resolve,
- so `sum_weights`/`effective_n`/`n_psu` — and `df_survey` on implicit-PSU
- designs — differed from the analytical arm by panel length on the same
- data. Metadata provenance only; estimates and inference unchanged.
-- **Documented (no behavior change)**: the event-study container's
- `df_survey` SCALAR — the fit's resolved scalar inference df — deliberately
- persists on bootstrapped fit-time and replayed surfaces (CS, DMLDiD,
- EfficientDiD identically) as the consumer channel HonestDiD's container
- branches read; the per-row `df` column is the inference-provenance channel
- that percentile bootstrap clears. Recorded as a REGISTRY Note with a
- cross-estimator parity pin.
-- **`survey_metadata` raw-scale provenance on the unit-level recompute**
- (CallawaySantAnna panel + repeated-cross-section lanes,
- `TripleDifference`/`StaggeredTripleDifference` staggered engine,
- `ContinuousDiD` analytical branch, `EfficientDiD`): the recompute passed
- the RESOLVED (mean-1 rescaled) weights as `compute_survey_metadata`'s
- raw weights, so `sum_weights`/`weight_range` reported the normalized
- scale instead of the user's original weight scale. They now report the
- raw scale, matching every other estimator (DMLDiD got the pattern in
- its survey PR). For previously-successful fits whose survey design does
- not alias a mutated role column, this is metadata-provenance only:
- estimates, SEs, p-values, CIs, `df_survey`, `n_strata`, `n_psu` are
- byte-identical, and `effective_n`/`design_effect` are scale-invariant
- (unchanged within floating-point round-off). Additionally,
- `ContinuousDiD`'s zero-dose-unit filter now re-resolves the survey
- design from pristine input rows: a design column aliasing a mutated
- role column (e.g. `weights` naming the dose column) previously
- zero-weighted every never-treated unit on filtered fits (failing with
- "No valid (g,t) cells"); such fits now estimate under the user's
- original weights, consistent with the unfiltered path.
+
## [3.11.0] - 2026-08-29
diff --git a/CLAUDE.md b/CLAUDE.md
index 3a8a0c4c6..9fc94d64d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -171,7 +171,10 @@ When adding new functionality, the source of truth is:
with `:no-index:` on a module page keeps a canonical autosummary entry in
`docs/api/index.rst`) are CI-enforced by `tests/test_docs_ia.py`; the full list is in
CONTRIBUTING.md "Docs IA invariants".
-- **`CHANGELOG.md`** for release notes.
+- **`changelog.d/`** for release notes - one fragment file per PR (see
+ `changelog.d/README.md`), compiled into `CHANGELOG.md` at release by
+ `.claude/scripts/changelog_compile.py`. Never edit `## [Unreleased]` directly
+ (pointer-only, CI-enforced by `tests/test_changelog_fragments.py`).
- **`README.md`** for ONE LINE in the `## Estimators` flat catalog (or `## Diagnostics & Sensitivity` for diagnostic-class features). Do NOT add usage examples, parameter tables, per-estimator sections, or full bibliographies.
`/docs-impact` and `/docs-check` enforce these surfaces. See `CONTRIBUTING.md` "README is a landing page, not the docs" for the full convention.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 69da69c8e..6421e4c7b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -79,7 +79,9 @@ If you find yourself adding a usage example, a parameter table, or a multi-parag
5. **`README.md`** - Add ONLY:
- One line in the `## Estimators` catalog with the paper citation and RTD link
-6. **`CHANGELOG.md`** - Add a release-note bullet under the next unreleased version.
+6. **`changelog.d/`** - Add a release-note fragment `changelog.d/YYYYMMDD-.md`
+ (see `changelog.d/README.md` for the format). NEVER edit `CHANGELOG.md`'s
+ `## [Unreleased]` section directly - it is pointer-only and CI-enforced.
7. **`CLAUDE.md`** - Update only if adding new critical rules or design patterns.
@@ -105,11 +107,35 @@ The documentation site's information architecture is machine-enforced; a red
autosummary entry in `docs/api/index.rst`, else `:class:` cross-references
to it render as dead text.
+### Changelog fragments (CI-enforced by `tests/test_changelog_fragments.py`)
+
+Release notes are per-PR fragment files under `changelog.d/` so that parallel
+PRs never conflict on `CHANGELOG.md`:
+
+1. Write your entry as `changelog.d/YYYYMMDD-.md` - one file per PR,
+ `### ` blocks with the bullets exactly as they should appear in
+ the changelog. Format spec: `changelog.d/README.md`.
+2. `CHANGELOG.md`'s `## [Unreleased]` section stays pointer-only between
+ releases; any direct edit fails the docs-tests lane.
+3. At release, `/bump-version` runs
+ `.claude/scripts/changelog_compile.py compile`, which merges the fragments
+ into the new version section (oldest-first within each category) and
+ deletes them.
+4. Rebasing a branch created before this system: move your old
+ `## [Unreleased]` bullet into a new fragment file.
+
+Rollback (if the system is ever removed): paste the current fragments' blocks
+back under `## [Unreleased]` (dropping the pointer comment), delete
+`changelog.d/`, `.claude/scripts/changelog_compile.py`, and
+`tests/test_changelog_fragments.py`, revert the `docs-tests.yml` /
+`rust-test.yml` filter entries and step, and restore the pre-change
+`bump-version` steps from git history.
+
### For Bug Fixes or Minor Enhancements
- Update relevant docstrings
- Add/update tests
-- Update `CHANGELOG.md`
+- Add a `changelog.d/YYYYMMDD-.md` fragment (never edit `## [Unreleased]` directly)
- **If methodology-related**: Update `docs/methodology/REGISTRY.md` edge cases section
- **README is almost never the right place** - skip it unless the bug was in a README claim
diff --git a/DEFERRED.md b/DEFERRED.md
index 5690c1c13..cab4d793a 100644
--- a/DEFERRED.md
+++ b/DEFERRED.md
@@ -80,6 +80,7 @@ For survey-specific limitations (`NotImplementedError` paths), see the
| Issue | Location | PR | Priority |
|-------|----------|----|----------|
+| Hard "changelog fragment required" CI gate (diff-aware job + exemption convention), and extending the fragment pattern to the other shared-file collision surfaces (TODO.md / DEFERRED.md / docs/v4-deprecations.yaml rows — rare, trivially resolvable one-line conflicts today). The shipped system enforces only the pointer-only `## [Unreleased]` invariant plus prose/reviewer nudges. | `changelog.d/` | #806 | Low |
| RDDensityTest public bandwidth-selector helper (an `rdbwdensity`-equivalent exposed function) and a Tutorial-28 executed density-test demo cell (Act 4d; needs notebook re-execution + drift-suite updates): the selector ships internal-only and the tutorial carries a prose pointer for now. | `diff_diff/rddensity.py`, `docs/tutorials/28_rdd_scholarship_illusion.ipynb` | rddensity PR-B | Low |
| LW 2025 Sec 4.3 all-eventually-treated mode for LWDiD (drop `D_infinity`, effects relative to the last cohort as reference, last cohort's own effect not estimable). Current behavior (by decision, see the REGISTRY LWDiD Sec 4.3 note): such designs raise ValueError under both control strategies rather than silently truncating | `diff_diff/lwdid_staggered.py` | #588 | Low |
| LWDiD sampling weights / `survey_design=`: `fit()` accepts no weight argument on any path (passing `survey_design=` raises `TypeError`) — the LW papers derive the rolling transformation and the collapsed exact/HC/CR1 inference for unweighted panels, and no weighted counterpart of the transformation or exact-inference layer has been derived. Documented scope exclusion (REGISTRY LWDiD Edge-cases Note, `docs/api/lwdid.rst` Scope limitations note, survey-support matrix row, survey-roadmap Current Limitations row) | `diff_diff/lwdid.py` | LWDiD release audit | Low |
diff --git a/changelog.d/20260829-event-study-df-provenance.md b/changelog.d/20260829-event-study-df-provenance.md
new file mode 100644
index 000000000..97f444708
--- /dev/null
+++ b/changelog.d/20260829-event-study-df-provenance.md
@@ -0,0 +1,28 @@
+### Fixed
+- **Per-row event-study df provenance (M-092 completion)** for the four
+ remaining holes — `EfficientDiD`, `ImputationDiD`, `ContinuousDiD`, and
+ `HeterogeneousAdoptionDiD`: each results class gains a results-level
+ `event_study_df` scalar (appended last; positional `__init__` indexes
+ unchanged) threaded into the unified event-study container's per-row `df`
+ column, which was all-NaN even on survey fits whose p-values were governed
+ by a finite survey df. Finite on analytical survey fits (ImputationDiD:
+ the final replicate-override df, level-matched on replicate replays, lead
+ rows included; EfficientDiD: the post-overall snapshot; HAD: the
+ unit-level design df); `None` — never the replicate-undefined `0`
+ sentinel — on non-survey fits, on bootstrapped fits (percentile inference
+ used no df, matching the shipped producer convention), and when no
+ event-study surface was built. Inference values are unchanged everywhere.
+- **`ContinuousDiD` `survey_metadata` granularity unified across inference
+ branches**: the bootstrap and degenerate no-post-cells arms now publish
+ the same UNIT-level metadata as the analytical arm (the
+ CS/EfficientDiD convention); previously they kept the obs-level resolve,
+ so `sum_weights`/`effective_n`/`n_psu` — and `df_survey` on implicit-PSU
+ designs — differed from the analytical arm by panel length on the same
+ data. Metadata provenance only; estimates and inference unchanged.
+- **Documented (no behavior change)**: the event-study container's
+ `df_survey` SCALAR — the fit's resolved scalar inference df — deliberately
+ persists on bootstrapped fit-time and replayed surfaces (CS, DMLDiD,
+ EfficientDiD identically) as the consumer channel HonestDiD's container
+ branches read; the per-row `df` column is the inference-provenance channel
+ that percentile bootstrap clears. Recorded as a REGISTRY Note with a
+ cross-estimator parity pin.
diff --git a/changelog.d/20260829-n-bootstrap-and-honest-did-df.md b/changelog.d/20260829-n-bootstrap-and-honest-did-df.md
new file mode 100644
index 000000000..950b9b5ef
--- /dev/null
+++ b/changelog.d/20260829-n-bootstrap-and-honest-did-df.md
@@ -0,0 +1,18 @@
+### Changed
+- **`n_bootstrap` type guards aligned onto `utils.validate_n_bootstrap`**
+ for the estimators the M-081 sweep deliberately left out —
+ `HeterogeneousAdoptionDiD`, `ChaisemartinDHaultfoeuille`, `TROP`,
+ `SyntheticDiD` (jackknife lane included), plus the two HAD pretest
+ helpers (`stute_test`, `stute_joint_pretest`): previously-accepted
+ type-blind values now raise the shared message — `True` (silently ran as
+ 1 replicate on HAD/dCDH), floats like `2.5` (passed the `>= 2` floors),
+ and bool/negative under SyntheticDiD's jackknife floor exemption. The
+ estimator-specific floors are unchanged and keep their own messages for
+ non-negative sub-floor integers (TROP/SDiD `n_bootstrap=1`, HAD `0`);
+ NEGATIVE values now surface the shared validator's message instead of
+ each estimator's former wording.
+- **`honest_did` inference-df resolution consolidated** onto the shared
+ `aggregation.resolve_inference_df()` (three duplicated precedence blocks
+ removed). Same precedence; `HonestDiDResults.df_survey` is now
+ float-typed (`31.0` where it was `31`), and a fractional `df_inference`
+ is preserved instead of truncated.
diff --git a/changelog.d/20260829-survey-metadata-raw-scale.md b/changelog.d/20260829-survey-metadata-raw-scale.md
new file mode 100644
index 000000000..94b0f6c27
--- /dev/null
+++ b/changelog.d/20260829-survey-metadata-raw-scale.md
@@ -0,0 +1,20 @@
+### Fixed
+- **`survey_metadata` raw-scale provenance on the unit-level recompute**
+ (CallawaySantAnna panel + repeated-cross-section lanes,
+ `TripleDifference`/`StaggeredTripleDifference` staggered engine,
+ `ContinuousDiD` analytical branch, `EfficientDiD`): the recompute passed
+ the RESOLVED (mean-1 rescaled) weights as `compute_survey_metadata`'s
+ raw weights, so `sum_weights`/`weight_range` reported the normalized
+ scale instead of the user's original weight scale. They now report the
+ raw scale, matching every other estimator (DMLDiD got the pattern in
+ its survey PR). For previously-successful fits whose survey design does
+ not alias a mutated role column, this is metadata-provenance only:
+ estimates, SEs, p-values, CIs, `df_survey`, `n_strata`, `n_psu` are
+ byte-identical, and `effective_n`/`design_effect` are scale-invariant
+ (unchanged within floating-point round-off). Additionally,
+ `ContinuousDiD`'s zero-dose-unit filter now re-resolves the survey
+ design from pristine input rows: a design column aliasing a mutated
+ role column (e.g. `weights` naming the dose column) previously
+ zero-weighted every never-treated unit on filtered fits (failing with
+ "No valid (g,t) cells"); such fits now estimate under the user's
+ original weights, consistent with the unfiltered path.
diff --git a/changelog.d/20260830-changelog-fragments.md b/changelog.d/20260830-changelog-fragments.md
new file mode 100644
index 000000000..9bf7d75e2
--- /dev/null
+++ b/changelog.d/20260830-changelog-fragments.md
@@ -0,0 +1,11 @@
+### Internal
+- **Changelog fragments**: release notes are now authored as per-PR files
+ under `changelog.d/` (see `changelog.d/README.md`) instead of editing
+ `CHANGELOG.md`'s `## [Unreleased]` section, which stays pointer-only
+ between releases (CI-enforced by `tests/test_changelog_fragments.py`) so
+ concurrent PRs no longer conflict on the changelog. At release,
+ `.claude/scripts/changelog_compile.py compile` merges the fragments into
+ the new version section and deletes them. One ordering change relative to
+ the old convention: within a category, compiled release sections list
+ entries oldest-first (ascending fragment-filename order) rather than the
+ newest-first order that prepending into Unreleased produced.
diff --git a/changelog.d/20260830-dml-s42-fixtures.md b/changelog.d/20260830-dml-s42-fixtures.md
new file mode 100644
index 000000000..fdb3e758b
--- /dev/null
+++ b/changelog.d/20260830-dml-s42-fixtures.md
@@ -0,0 +1,11 @@
+### Added
+- **Chang (2020) §4.2.2 RCS simulation-DGP replication** (`tests/test_methodology_dml_did.py`,
+ DML PR-B2): the paper's own kernel-design repeated-cross-section DGP as
+ maintainer validation fixtures for `DMLDiD(panel=False)` — a DGP-shape pin
+ (distributions, all three innovation scales, the design's built-in confounded
+ contrast → θ₀+1, and both correct-specification facts), seed-pinned recovery at
+ both paper sample sizes with a discriminating comparison against the unadjusted
+ contrast, and a slow Monte Carlo coverage lane. The §4.2 parameterizations are
+ extracted into the paper review; the §4.2.1 ML design is documented as not
+ replicable with the bundled unpenalized learners (narrowed TODO row) and the
+ REGISTRY carries the replication-scope Note.
diff --git a/changelog.d/README.md b/changelog.d/README.md
new file mode 100644
index 000000000..2b0d2d6a0
--- /dev/null
+++ b/changelog.d/README.md
@@ -0,0 +1,54 @@
+# Changelog fragments
+
+Release notes are authored here as **one file per PR** instead of editing
+`CHANGELOG.md`'s `## [Unreleased]` section (which stays pointer-only between
+releases — CI-enforced by `tests/test_changelog_fragments.py`, so concurrent
+PRs never conflict on the changelog). At release time
+`.claude/scripts/changelog_compile.py compile` merges every fragment into the
+new `## [X.Y.Z] - DATE` section and deletes the fragment files.
+
+## Filename
+
+`YYYYMMDD-.md` — e.g. `20260830-dml-s42-fixtures.md`.
+
+- `YYYYMMDD` = authoring date (must be a real calendar date).
+- `` = kebab-case topic, usually derived from the branch name; keep it
+ unique per PR. (Two same-day PRs picking the identical slug produce an
+ add/add conflict on a brand-new file — trivially resolved by renaming one,
+ and strictly cheaper than the same-line CHANGELOG conflicts this system
+ replaces.)
+- Within a category, fragments compile **oldest-first** (ascending filename
+ sort).
+
+## Body
+
+One or more category blocks, written exactly as the bullets should appear in
+`CHANGELOG.md` (same voice and style as existing entries):
+
+```markdown
+### Fixed
+- **Headline of the fix**: details, wrapped with
+ two-space continuation lines.
+ - nested sub-bullets are fine too.
+```
+
+Rules (validated by `changelog_compile.py check`):
+
+- A block starts with `### ` and must contain at least one
+ top-level bullet (`- ` at column 0). Wrapped prose and nested sub-bullets
+ are indented continuation lines. Blank lines are allowed anywhere.
+- Nothing outside category blocks; no `## ` headers.
+- Allowed categories (this list mirrors the compiler constant in
+ `.claude/scripts/changelog_compile.py`; the mirror is test-pinned):
+ `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`,
+ `Performance`, `Documentation`, `Testing`, `Breaking Changes`,
+ `Behavioral Changes`, `Internal`.
+- 4.0-program PRs must still name the flipped `M-xxx` row ids in the
+ fragment (the `docs/v4-design.md` per-PR obligation).
+
+## Rebasing an older branch
+
+A branch created before this system that added an `## [Unreleased]` bullet
+will conflict on rebase and then fail the pointer-only guard: move your
+bullet into a new fragment file here and leave the Unreleased section as the
+pointer comment.
diff --git a/docs/v4-design.md b/docs/v4-design.md
index 30a71cf13..ca5b2b045 100644
--- a/docs/v4-design.md
+++ b/docs/v4-design.md
@@ -76,7 +76,7 @@ asserting (a) `pytest.warns` on the old surface with the migration message, and
before the PR. Every removal PR follows the
`tests/test_had_dual_knob_deprecation.py` pattern: canonical-surface positive
smoke + `TypeError`/`AttributeError` removal pin per surface. Matrix rows flip
-in the same diff; the PR's CHANGELOG entry names the flipped row ids.
+in the same diff; the PR's changelog fragment names the flipped row ids.
## 3. Target 4.0 surface
@@ -845,7 +845,7 @@ without a deprecation window).
Boundary rule, verbatim: **anything two later PRs could disagree about lives
above; anything only one PR cares about stays in that PR's plan.**
-| Phase | Ships in | PRs (each: dedicated shim/removal tests + matrix flips + CHANGELOG naming flipped row ids) |
+| Phase | Ships in | PRs (each: dedicated shim/removal tests + matrix flips + changelog fragment naming flipped row ids) |
|---|---|---|
| 1 (this PR) | - | Spec + matrix + enforcement test + support edits |
| 2: contract foundations | 3.9 | (a) results base + unified event-study representation [M-092] + to_dict completion + the Diagnostic marker base on the diagnostic result roster [M-091] (section 3.5); (b) `aggregate()` + fit(aggregate=) shims [M-020..M-027] [M-139] (M-020's shim already shipped; M-139 is the HAD workflow twin, a pre-cut amendment); (c) param renames [M-030..M-047] [M-084] [M-086..M-089] + their results-field mirrors [M-094] [M-095] (section 8 rule 9) + the public-function completeness sweep [M-097..M-113] (section 8 rule 10) + the dCDH results mirror [M-114] + the fourth `robust` site [M-115] + the 2(c)-ii missed-rename amendments [M-136..M-138] (LPDiD `level` value; the two post-dummy diagnostics params) + BaseEstimator mixin + ContinuousDiD covariates move; (d) alias introduction [M-062] (the Spillover introduction is cancelled [M-063]) + the alias-diet `__getattr__` warning shim [M-135] + wrapper deprecations [M-070..M-077] + the two inference-surface policies: `n_bootstrap` semantic unification [M-081] and the wild-cluster-bootstrap roster guard [M-096]; shipped insertions (all done): the aggregate contract [M-122], the ETWFE reference-period family [M-123] [M-124] [M-125], and the variance-consolidation program [M-126] [M-127] |
diff --git a/tests/test_changelog_fragments.py b/tests/test_changelog_fragments.py
new file mode 100644
index 000000000..c611ff533
--- /dev/null
+++ b/tests/test_changelog_fragments.py
@@ -0,0 +1,619 @@
+"""Guards for the changelog-fragment system (.claude/scripts/changelog_compile.py).
+
+Three contracts:
+
+1. **Repo-state invariant** — `CHANGELOG.md`'s `## [Unreleased]` section is
+ pointer-only and every `changelog.d/` fragment is valid, so a PR that
+ edits Unreleased directly (the old convention) fails CI in the docs-tests
+ lane.
+2. **Compiler behavior** — `check` negatives and the `compile` assembly
+ (category order, contiguous joins, comparison links, the exit-4
+ idempotent re-run, the downgrade/dirty/empty guards) in tmp fixtures.
+3. **Workflow pins** — docs-tests.yml's `on:` path filters carry the
+ fragment surfaces and the doc-snippets job actually invokes this file
+ (path filters alone would go silently dead if the step were deleted).
+
+Skipped when the script is absent (installed distribution). The module must
+IMPORT cleanly on the Python 3.9 CI leg, so annotations stay 3.9-safe.
+"""
+
+import importlib.util
+import pathlib
+import shutil
+import subprocess
+
+import pytest
+
+
+def _find_repo_root():
+ cand = pathlib.Path(__file__).resolve().parent.parent
+ if (cand / ".claude" / "scripts" / "changelog_compile.py").exists():
+ return cand
+ try:
+ root = subprocess.check_output(
+ ["git", "rev-parse", "--show-toplevel"], stderr=subprocess.DEVNULL, text=True
+ ).strip()
+ cand = pathlib.Path(root)
+ if (cand / ".claude" / "scripts" / "changelog_compile.py").exists():
+ return cand
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ pass
+ return None
+
+
+_REPO_ROOT = _find_repo_root()
+pytestmark = pytest.mark.skipif(
+ _REPO_ROOT is None, reason="changelog_compile.py not found (installed distribution)"
+)
+
+POINTER = (
+ ""
+)
+
+
+@pytest.fixture(scope="module")
+def mod():
+ path = _REPO_ROOT / ".claude" / "scripts" / "changelog_compile.py"
+ spec = importlib.util.spec_from_file_location("changelog_compile", path)
+ m = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(m)
+ return m
+
+
+# ---------------------------------------------------------------------------
+# Fixture helpers
+# ---------------------------------------------------------------------------
+
+MINIMAL_CHANGELOG = (
+ "# Changelog\n\n"
+ "## [Unreleased]\n\n" + POINTER + "\n\n"
+ "## [1.2.0] - 2026-01-15\n\n"
+ "### Added\n"
+ "- old entry\n\n"
+ "## [1.1.0] - 2026-01-01\n\n"
+ "### Fixed\n"
+ "- older entry\n\n"
+ "[1.2.0]: https://github.com/x/y/compare/v1.1.0...v1.2.0\n"
+ "[1.1.0]: https://github.com/x/y/releases/tag/v1.1.0\n"
+)
+
+GOOD_FRAGMENT = "### Fixed\n- **a fix**: details, wrapped with\n a continuation line.\n"
+
+
+def make_repo(tmp_path, changelog=MINIMAL_CHANGELOG, fragments=None, readme=True):
+ d = tmp_path / "changelog.d"
+ d.mkdir()
+ if readme:
+ (d / "README.md").write_text("spec\n")
+ for name, content in (fragments or {}).items():
+ (d / name).write_text(content)
+ (tmp_path / "CHANGELOG.md").write_text(changelog)
+ return tmp_path
+
+
+def check_findings(mod, root):
+ findings, _ = mod.run_check(root)
+ return findings
+
+
+@pytest.fixture
+def git_commit_all():
+ """git init + commit everything in a fixture repo (skips if git absent).
+
+ Call with init_only=True to initialize without committing (for tests
+ exercising the not-committed-in-HEAD path).
+ """
+ git = shutil.which("git")
+ if git is None:
+ pytest.skip("git unavailable")
+ env = {
+ "GIT_AUTHOR_NAME": "t",
+ "GIT_AUTHOR_EMAIL": "t@t",
+ "GIT_COMMITTER_NAME": "t",
+ "GIT_COMMITTER_EMAIL": "t@t",
+ "PATH": "/usr/bin:/bin",
+ }
+
+ def _commit(root, init_only=False):
+ subprocess.run([git, "init", "-q"], cwd=root, check=True)
+ if not init_only:
+ subprocess.run([git, "add", "-A"], cwd=root, check=True, env=env)
+ subprocess.run([git, "commit", "-q", "-m", "seed"], cwd=root, check=True, env=env)
+
+ return _commit
+
+
+# ---------------------------------------------------------------------------
+# 1. Live-repo invariant
+# ---------------------------------------------------------------------------
+
+
+class TestLiveRepo:
+ def test_check_passes_on_repo(self, mod):
+ assert check_findings(mod, _REPO_ROOT) == []
+
+ def test_readme_mirrors_compiler_categories(self, mod):
+ """README<->compiler parity (the TestLintWorkflowPinSync convention):
+ every category, in order, appears as a backticked token in the
+ README's category list."""
+ readme = (_REPO_ROOT / "changelog.d" / "README.md").read_text()
+ tokens = ["`%s`" % c for c in mod.CATEGORIES]
+ positions = [readme.find(t) for t in tokens]
+ assert all(
+ p >= 0 for p in positions
+ ), "changelog.d/README.md is missing categories: " + ", ".join(
+ t for t, p in zip(tokens, positions) if p < 0
+ )
+ assert positions == sorted(positions), (
+ "changelog.d/README.md lists categories in a different order "
+ "than the compiler constant"
+ )
+
+ def test_direct_unreleased_edit_fails_check(self, mod, tmp_path):
+ text = (_REPO_ROOT / "CHANGELOG.md").read_text()
+ polluted = text.replace(
+ POINTER, POINTER + "\n\n### Added\n- a bullet added the old way\n", 1
+ )
+ root = tmp_path
+ (root / "changelog.d").mkdir()
+ (root / "changelog.d" / "README.md").write_text("spec\n")
+ (root / "CHANGELOG.md").write_text(polluted)
+ assert any("pointer comment" in f for f in check_findings(mod, root))
+
+
+# ---------------------------------------------------------------------------
+# 2a. check negatives / positives
+# ---------------------------------------------------------------------------
+
+
+class TestCheck:
+ @pytest.mark.parametrize(
+ "name",
+ [
+ "no-date-prefix.md",
+ "2026-08-30-dashes.md",
+ "20260830_underscore.md",
+ "20269999-impossible-date.md",
+ "20260830-UPPER.md",
+ "20260830-slug.txt",
+ ],
+ )
+ def test_bad_fragment_names(self, mod, tmp_path, name):
+ root = make_repo(tmp_path, fragments={name: GOOD_FRAGMENT})
+ assert any(name in f for f in check_findings(mod, root))
+
+ @pytest.mark.parametrize(
+ "body,needle",
+ [
+ ("### Wat\n- bullet\n", "unknown category"),
+ ("", "no '### ' block"),
+ ("### Fixed\n", "no top-level bullet"),
+ ("### Fixed\n- \n", "empty top-level bullet"),
+ ("### Fixed\n- \n", "empty top-level bullet"),
+ ("### Fixed\n- ok\n## [9.9.9]\n", "'## ' header"),
+ ("- floating bullet\n### Fixed\n- ok\n", "outside any"),
+ ("### Fixed\nnot a bullet\n", "not a top-level bullet"),
+ ],
+ )
+ def test_bad_fragment_bodies(self, mod, tmp_path, body, needle):
+ root = make_repo(tmp_path, fragments={"20260830-x.md": body})
+ assert any(needle in f for f in check_findings(mod, root))
+
+ def test_missing_readme_fails(self, mod, tmp_path):
+ root = make_repo(tmp_path, readme=False)
+ assert any("README.md" in f for f in check_findings(mod, root))
+
+ def test_subdirectory_and_stray_file_fail(self, mod, tmp_path):
+ root = make_repo(tmp_path)
+ (root / "changelog.d" / "nested").mkdir()
+ assert any("subdirectories" in f for f in check_findings(mod, root))
+
+ def test_dotfile_ignored(self, mod, tmp_path):
+ root = make_repo(tmp_path, fragments={"20260830-ok.md": GOOD_FRAGMENT})
+ (root / "changelog.d" / ".DS_Store").write_bytes(b"\x00junk")
+ assert check_findings(mod, root) == []
+
+ def test_missing_pointer_fails(self, mod, tmp_path):
+ root = make_repo(tmp_path, changelog=MINIMAL_CHANGELOG.replace(POINTER + "\n\n", ""))
+ assert any("pointer comment" in f for f in check_findings(mod, root))
+
+ def test_duplicated_pointer_fails(self, mod, tmp_path):
+ root = make_repo(
+ tmp_path, changelog=MINIMAL_CHANGELOG.replace(POINTER, POINTER + "\n" + POINTER)
+ )
+ assert any("pointer comment" in f for f in check_findings(mod, root))
+
+ def test_missing_unreleased_header_fails(self, mod, tmp_path):
+ root = make_repo(tmp_path, changelog=MINIMAL_CHANGELOG.replace("## [Unreleased]\n", ""))
+ assert any("Unreleased" in f for f in check_findings(mod, root))
+
+ def test_check_rejects_symlink_fragment_before_reading(self, mod, tmp_path):
+ # A symlink at a fragment path must be a finding at check level (CI),
+ # not just at compile time — its target is mutable out-of-band.
+ root = make_repo(tmp_path)
+ target = root / "real-content.md"
+ target.write_text(GOOD_FRAGMENT)
+ (root / "changelog.d" / "20260830-x.md").symlink_to(target)
+ assert any("not a regular file" in f for f in check_findings(mod, root))
+
+ def test_check_rejects_dangling_symlink_without_crashing(self, mod, tmp_path):
+ root = make_repo(tmp_path)
+ (root / "changelog.d" / "20260830-x.md").symlink_to(root / "does-not-exist.md")
+ assert any("not a regular file" in f for f in check_findings(mod, root))
+
+ def test_check_rejects_undecodable_fragment(self, mod, tmp_path):
+ root = make_repo(tmp_path)
+ (root / "changelog.d" / "20260830-x.md").write_bytes(b"### Fixed\n- \xff\xfe junk\n")
+ assert any("unreadable" in f for f in check_findings(mod, root))
+
+ def test_duplicate_unreleased_headers_fail(self, mod, tmp_path):
+ # A second '## [Unreleased]' section could carry direct bullets that
+ # the first-match slice never inspects; exactly one header is allowed.
+ changelog = MINIMAL_CHANGELOG.replace(
+ "## [1.2.0] - 2026-01-15\n",
+ "## [Unreleased]\n\n### Added\n- smuggled direct bullet\n\n"
+ "## [1.2.0] - 2026-01-15\n",
+ )
+ root = make_repo(tmp_path, changelog=changelog)
+ assert any("exactly one is allowed" in f for f in check_findings(mod, root))
+
+ def test_eof_only_unreleased_header_is_finding_not_traceback(self, mod, tmp_path):
+ # File ending exactly at the header with no trailing newline must
+ # produce a validation finding, not a ValueError.
+ changelog = "# Changelog\n\n## [Unreleased]"
+ root = make_repo(tmp_path, changelog=changelog)
+ assert any("pointer comment" in f for f in check_findings(mod, root))
+
+ def test_blank_lines_around_pointer_tolerated(self, mod, tmp_path):
+ root = make_repo(
+ tmp_path, changelog=MINIMAL_CHANGELOG.replace(POINTER, "\n" + POINTER + "\n\n")
+ )
+ assert check_findings(mod, root) == []
+
+
+# ---------------------------------------------------------------------------
+# 2b. compile behavior
+# ---------------------------------------------------------------------------
+
+
+def run_compile(mod, root, version="1.3.0", date="2026-08-30", allow_dirty=True):
+ return mod.run_compile(root, version, date, allow_dirty)
+
+
+class TestCompile:
+ def test_category_order_and_contiguous_join(self, mod, tmp_path):
+ root = make_repo(
+ tmp_path,
+ fragments={
+ "20260829-bbb.md": "### Fixed\n- fix from bbb\n\n### Added\n- add from bbb\n",
+ "20260828-aaa.md": "### Fixed\n- fix from aaa\n",
+ "20260830-ccc.md": "### Internal\n- internal from ccc\n",
+ },
+ )
+ assert run_compile(mod, root) == 0
+ text = (root / "CHANGELOG.md").read_text()
+ section = text[text.index("## [1.3.0]") : text.index("## [1.2.0]")]
+ # Category order: Added before Fixed before Internal.
+ assert (
+ section.index("### Added") < section.index("### Fixed") < section.index("### Internal")
+ )
+ # Within Fixed: ascending filename order, joined contiguously.
+ assert "- fix from aaa\n- fix from bbb" in section
+
+ def test_header_and_link(self, mod, tmp_path):
+ root = make_repo(tmp_path, fragments={"20260830-x.md": GOOD_FRAGMENT})
+ assert run_compile(mod, root) == 0
+ text = (root / "CHANGELOG.md").read_text()
+ assert "## [1.3.0] - 2026-08-30\n" in text
+ link = "[1.3.0]: https://github.com/x/y/compare/v1.2.0...v1.3.0\n"
+ assert link in text
+ # Inserted immediately above the PREV link line.
+ assert text.index(link) < text.index("[1.2.0]:")
+
+ def test_fragments_deleted_and_round_trip(self, mod, tmp_path):
+ root = make_repo(tmp_path, fragments={"20260830-x.md": GOOD_FRAGMENT})
+ assert run_compile(mod, root) == 0
+ remaining = [p.name for p in (root / "changelog.d").iterdir() if not p.name.startswith(".")]
+ assert remaining == ["README.md"]
+ # Post-compile tree still passes check, and re-compile exits 4.
+ assert check_findings(mod, root) == []
+ assert run_compile(mod, root) == 4
+
+ def test_prev_is_semver_max_not_string_max(self, mod, tmp_path):
+ changelog = (
+ "# Changelog\n\n"
+ "## [Unreleased]\n\n" + POINTER + "\n\n"
+ "## [3.11.0] - 2026-08-29\n\n### Added\n- x\n\n"
+ "## [3.9.1] - 2026-08-17\n\n### Fixed\n- y\n\n"
+ "[3.11.0]: https://github.com/x/y/compare/v3.9.1...v3.11.0\n"
+ "[3.9.1]: https://github.com/x/y/releases/tag/v3.9.1\n"
+ )
+ root = make_repo(tmp_path, changelog=changelog, fragments={"20260830-x.md": GOOD_FRAGMENT})
+ # 3.10.0 <= semver-max 3.11.0 even though "3.9.1" is the string max.
+ assert run_compile(mod, root, version="3.10.0") != 0
+ assert run_compile(mod, root, version="3.12.0") == 0
+ assert "compare/v3.11.0...v3.12.0" in (root / "CHANGELOG.md").read_text()
+
+ def test_version_not_above_prev_rejected(self, mod, tmp_path):
+ root = make_repo(tmp_path, fragments={"20260830-x.md": GOOD_FRAGMENT})
+ assert run_compile(mod, root, version="1.2.0") != 0 # equal->exit-4 lane guards apply
+ assert run_compile(mod, root, version="1.1.9") == 1
+
+ @pytest.mark.parametrize("version", ["03.1.0", "1.02.0", "1.0", "v1.3.0", "1.3.0.0"])
+ def test_bad_versions_rejected(self, mod, tmp_path, version):
+ root = make_repo(tmp_path, fragments={"20260830-x.md": GOOD_FRAGMENT})
+ assert run_compile(mod, root, version=version) == 2
+
+ @pytest.mark.parametrize("date", ["20260830", "2026-8-30", "2026-13-01", "yesterday"])
+ def test_bad_dates_rejected(self, mod, tmp_path, date):
+ root = make_repo(tmp_path, fragments={"20260830-x.md": GOOD_FRAGMENT})
+ assert run_compile(mod, root, date=date) == 2
+
+ def test_exit4_requires_no_fragments(self, mod, tmp_path):
+ root = make_repo(tmp_path, fragments={"20260830-x.md": GOOD_FRAGMENT})
+ assert run_compile(mod, root) == 0
+ (root / "changelog.d" / "20260831-late.md").write_text(GOOD_FRAGMENT)
+ assert run_compile(mod, root) == 1 # header exists AND fragments remain
+
+ def test_exit4_requires_header_is_prev(self, mod, tmp_path):
+ root = make_repo(tmp_path) # no fragments
+ # 1.1.0 exists but is older than PREV=1.2.0 -> downgrade refusal.
+ assert run_compile(mod, root, version="1.1.0") == 1
+
+ def test_exit4_requires_nonempty_section(self, mod, tmp_path):
+ changelog = MINIMAL_CHANGELOG.replace(
+ "## [1.2.0] - 2026-01-15\n\n### Added\n- old entry\n",
+ "## [1.2.0] - 2026-01-15\n",
+ )
+ root = make_repo(tmp_path, changelog=changelog)
+ assert run_compile(mod, root, version="1.2.0") == 1
+
+ def test_exit4_requires_dated_header(self, mod, tmp_path):
+ changelog = MINIMAL_CHANGELOG.replace("## [1.2.0] - 2026-01-15", "## [1.2.0]")
+ root = make_repo(tmp_path, changelog=changelog)
+ assert run_compile(mod, root, version="1.2.0") == 1
+
+ def test_exit4_rejects_noncanonical_header_date(self, mod, tmp_path):
+ # An impossible/malformed date must not be certified and propagated
+ # into CITATION.cff via the exit-4 RELEASE_DATE reuse.
+ changelog = MINIMAL_CHANGELOG.replace("## [1.2.0] - 2026-01-15", "## [1.2.0] - 2026-99-99")
+ root = make_repo(tmp_path, changelog=changelog)
+ assert run_compile(mod, root, version="1.2.0") == 1
+
+ def test_compile_refuses_duplicate_unreleased_headers(self, mod, tmp_path):
+ # check runs as compile's first step, so a duplicate Unreleased
+ # section blocks compilation before any insertion or deletion.
+ changelog = MINIMAL_CHANGELOG.replace(
+ "## [1.2.0] - 2026-01-15\n",
+ "## [Unreleased]\n\n### Added\n- smuggled direct bullet\n\n"
+ "## [1.2.0] - 2026-01-15\n",
+ )
+ root = make_repo(tmp_path, changelog=changelog, fragments={"20260830-x.md": GOOD_FRAGMENT})
+ assert run_compile(mod, root) == 1
+ assert (root / "changelog.d" / "20260830-x.md").exists()
+
+ def test_duplicate_release_headers_rejected_not_certified(self, mod, tmp_path):
+ # Two '## [1.2.0]' sections with a nonempty first section and a valid
+ # comparison link: exit 4 must NOT certify this (the loop inspects
+ # only the first match); any duplicated release version is a corrupt
+ # changelog and exits 1.
+ changelog = MINIMAL_CHANGELOG.replace(
+ "## [1.1.0] - 2026-01-01\n",
+ "## [1.2.0] - 2026-01-15\n\n### Fixed\n- duplicate section\n\n"
+ "## [1.1.0] - 2026-01-01\n",
+ )
+ root = make_repo(tmp_path, changelog=changelog) # no fragments
+ assert run_compile(mod, root, version="1.2.0") == 1
+
+ def test_exit4_target_header_at_eof_is_error_not_traceback(self, mod, tmp_path):
+ # An existing target header ending the file with no trailing newline
+ # must produce the empty-section error, not a ValueError (the same
+ # EOF anti-pattern fixed in _unreleased_slice).
+ changelog = (
+ "# Changelog\n\n"
+ "## [Unreleased]\n\n" + POINTER + "\n\n"
+ "## [1.1.0] - 2026-01-01\n\n### Fixed\n- older entry\n\n"
+ "[1.1.0]: https://github.com/x/y/releases/tag/v1.1.0\n"
+ "## [1.2.0] - 2026-01-15"
+ )
+ root = make_repo(tmp_path, changelog=changelog)
+ assert run_compile(mod, root, version="1.2.0") == 1
+
+ def test_exit4_rejects_sole_release_header(self, mod, tmp_path):
+ # A lone target header (no predecessor to anchor the comparison
+ # link) cannot be compiler output; exit 4 must not certify it.
+ changelog = (
+ "# Changelog\n\n"
+ "## [Unreleased]\n\n" + POINTER + "\n\n"
+ "## [1.2.0] - 2026-01-15\n\n"
+ "### Added\n"
+ "- only entry\n"
+ )
+ root = make_repo(tmp_path, changelog=changelog)
+ assert run_compile(mod, root, version="1.2.0") == 1
+
+ def test_exit4_rejects_wrongly_sourced_comparison_link(self, mod, tmp_path):
+ # The link must read compare/v...v;
+ # a link sourced from any other version was not produced by the
+ # compiler and must not be certified via exit 4.
+ changelog = MINIMAL_CHANGELOG.replace(
+ "[1.2.0]: https://github.com/x/y/compare/v1.1.0...v1.2.0\n",
+ "[1.2.0]: https://github.com/x/y/compare/v0.9.0...v1.2.0\n",
+ )
+ root = make_repo(tmp_path, changelog=changelog)
+ assert run_compile(mod, root, version="1.2.0") == 1
+
+ def test_exit4_requires_target_comparison_link(self, mod, tmp_path):
+ # A hand-built section without its comparison link is not a completed
+ # compile; bump-version's step 6 assumes the link exists on exit 4.
+ changelog = MINIMAL_CHANGELOG.replace(
+ "[1.2.0]: https://github.com/x/y/compare/v1.1.0...v1.2.0\n", ""
+ )
+ root = make_repo(tmp_path, changelog=changelog)
+ assert run_compile(mod, root, version="1.2.0") == 1
+
+ def test_no_fragments_no_header_rejected(self, mod, tmp_path):
+ root = make_repo(tmp_path)
+ assert run_compile(mod, root) == 1
+
+ def test_dirty_guard(self, mod, tmp_path, git_commit_all):
+ root = make_repo(tmp_path, fragments={"20260830-x.md": GOOD_FRAGMENT})
+ # Non-git root without --allow-dirty: fail-closed.
+ assert run_compile(mod, root, allow_dirty=False) == 1
+ # Git root with an uncommitted fragment: refused; --allow-dirty passes.
+ git_commit_all(root, init_only=True)
+ assert run_compile(mod, root, allow_dirty=False) == 1
+ assert run_compile(mod, root, allow_dirty=True) == 0
+
+ def test_dirty_guard_catches_gitignored_fragment(self, mod, tmp_path, git_commit_all):
+ # `git status` never sees ignored untracked files; the guard is a
+ # committed-content check instead, so a fragment hidden by
+ # .git/info/exclude must be refused (it would be compiled into the
+ # release and deleted) and must survive the refused run. Baseline is
+ # committed FIRST so the ignored fragment is the sole dirty entry.
+ root = make_repo(tmp_path)
+ git_commit_all(root)
+ (root / ".git" / "info").mkdir(exist_ok=True)
+ (root / ".git" / "info" / "exclude").write_text("changelog.d/20260830-x.md\n")
+ (root / "changelog.d" / "20260830-x.md").write_text(GOOD_FRAGMENT)
+ assert run_compile(mod, root, allow_dirty=False) == 1
+ assert (root / "changelog.d" / "20260830-x.md").exists()
+
+ def test_dirty_guard_catches_status_hidden_untracked(self, mod, tmp_path, git_commit_all):
+ # status.showUntrackedFiles=no hides untracked files from
+ # `git status`; the committed-content guard must still refuse the
+ # uncommitted fragment.
+ root = make_repo(tmp_path)
+ git_commit_all(root)
+ git = shutil.which("git")
+ subprocess.run(
+ [git, "-C", str(root), "config", "status.showUntrackedFiles", "no"],
+ check=True,
+ )
+ (root / "changelog.d" / "20260830-x.md").write_text(GOOD_FRAGMENT)
+ assert run_compile(mod, root, allow_dirty=False) == 1
+ assert (root / "changelog.d" / "20260830-x.md").exists()
+
+ def test_dirty_guard_rejects_symlink_fragment(self, mod, tmp_path, git_commit_all):
+ # A symlink at a fragment path is refused outright: read_text()
+ # follows mutable target content, so it can never be certified as
+ # committed bytes.
+ root = make_repo(tmp_path)
+ git_commit_all(root)
+ target = root / "real-content.md"
+ target.write_text(GOOD_FRAGMENT)
+ (root / "changelog.d" / "20260830-x.md").symlink_to(target)
+ assert run_compile(mod, root, allow_dirty=False) == 1
+
+ def test_dirty_guard_rejects_modified_committed_fragment(self, mod, tmp_path, git_commit_all):
+ # A committed fragment whose worktree bytes drifted from the HEAD
+ # blob is refused (a status-free byte comparison).
+ root = make_repo(tmp_path, fragments={"20260830-x.md": GOOD_FRAGMENT})
+ git_commit_all(root)
+ (root / "changelog.d" / "20260830-x.md").write_text(
+ GOOD_FRAGMENT + "- uncommitted extra bullet\n"
+ )
+ assert run_compile(mod, root, allow_dirty=False) == 1
+
+ def test_dirty_guard_ignores_dotfiles(self, mod, tmp_path, git_commit_all):
+ # A stray .DS_Store must NOT trip the guard — dotfiles are never
+ # compiled or deleted, mirroring check's dotfile rule.
+ root = make_repo(tmp_path, fragments={"20260830-x.md": GOOD_FRAGMENT})
+ git_commit_all(root)
+ (root / "changelog.d" / ".DS_Store").write_bytes(b"\x00junk")
+ assert run_compile(mod, root, allow_dirty=False) == 0
+
+ def test_fragment_free_recovery_via_committed_stub(self, mod, tmp_path, git_commit_all):
+ # The documented fragment-free release recovery: write an
+ # ### Internal stub, COMMIT it, re-run WITHOUT --allow-dirty.
+ root = make_repo(tmp_path)
+ assert run_compile(mod, root, allow_dirty=False) == 1 # nothing to release
+ (root / "changelog.d" / "20260830-stub.md").write_text(
+ "### Internal\n- metadata-only re-release\n"
+ )
+ git_commit_all(root)
+ assert run_compile(mod, root, allow_dirty=False) == 0
+
+ def test_byte_stability_outside_edits(self, mod, tmp_path):
+ root = make_repo(tmp_path, fragments={"20260830-x.md": GOOD_FRAGMENT})
+ before = (root / "CHANGELOG.md").read_text()
+ assert run_compile(mod, root) == 0
+ after = (root / "CHANGELOG.md").read_text()
+ # The compiled file is exactly the original with (a) the new section
+ # inserted below the pointer block and (b) the new link line inserted
+ # above the [1.2.0]: link — everything else byte-identical.
+ head = before[: before.index("## [1.2.0]")]
+ mid = before[before.index("## [1.2.0]") : before.index("[1.2.0]:")]
+ tail = before[before.index("[1.2.0]:") :]
+ section = after[len(head) : after.index("## [1.2.0]")]
+ link = "[1.3.0]: https://github.com/x/y/compare/v1.2.0...v1.3.0\n"
+ assert after == head + section + mid + link + tail
+ assert section.startswith("## [1.3.0] - 2026-08-30\n")
+
+
+# ---------------------------------------------------------------------------
+# 3. Workflow pins (bounded slicing — never split on "pull_request:" alone)
+# ---------------------------------------------------------------------------
+
+REQUIRED_FILTER_ENTRIES = (
+ "tests/test_changelog_fragments.py",
+ "changelog.d/**",
+ "CHANGELOG.md",
+ ".claude/scripts/changelog_compile.py",
+)
+
+
+def _on_block(text):
+ """The workflow's `on:` block: from the `on:` line to the next
+ top-level (column-0) key."""
+ lines = text.splitlines(keepends=True)
+ start = next(i for i, ln in enumerate(lines) if ln.rstrip() == "on:")
+ end = next(
+ (
+ i
+ for i, ln in enumerate(lines[start + 1 :], start + 1)
+ if ln.rstrip() and ln[0] not in (" ", "\t", "#")
+ ),
+ len(lines),
+ )
+ return "".join(lines[start:end])
+
+
+class TestWorkflowPins:
+ @pytest.fixture(scope="class")
+ def docs_tests_text(self):
+ path = _REPO_ROOT / ".github" / "workflows" / "docs-tests.yml"
+ if not path.exists():
+ pytest.skip("docs-tests.yml not present")
+ return path.read_text()
+
+ @pytest.mark.parametrize("trigger", ["push:", "pull_request:"])
+ def test_path_filters_cover_fragment_surfaces(self, docs_tests_text, trigger):
+ on_block = _on_block(docs_tests_text)
+ tstart = on_block.index(trigger)
+ others = [
+ on_block.index(t)
+ for t in ("push:", "pull_request:", "schedule:", "workflow_dispatch:")
+ if t != trigger and t in on_block and on_block.index(t) > tstart
+ ]
+ tblock = on_block[tstart : min(others)] if others else on_block[tstart:]
+ for entry in REQUIRED_FILTER_ENTRIES:
+ assert f"'{entry}'" in tblock or f'"{entry}"' in tblock or f"- {entry}" in tblock, (
+ f"docs-tests.yml {trigger} paths filter is missing {entry!r} — "
+ "a fragment-only PR would run no CI guard"
+ )
+
+ def test_doc_snippets_step_invokes_guard(self, docs_tests_text):
+ jobs = docs_tests_text[docs_tests_text.index("\njobs:") :]
+ doc_snippets = jobs[jobs.index("doc-snippets:") :]
+ nxt = [
+ doc_snippets.index(j)
+ for j in ("sphinx-build:", "docs-deps-py39-smoke:")
+ if j in doc_snippets
+ ]
+ block = doc_snippets[: min(nxt)] if nxt else doc_snippets
+ assert "pytest tests/test_changelog_fragments.py" in block, (
+ "docs-tests.yml doc-snippets job no longer runs the changelog "
+ "fragment guard — the path filters alone are silently dead"
+ )