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" + )