diff --git a/.claude/commands/bump-version.md b/.claude/commands/bump-version.md index 51181597..3cd2613d 100644 --- a/.claude/commands/bump-version.md +++ b/.claude/commands/bump-version.md @@ -35,17 +35,25 @@ Files that need updating: - If invalid, ask user to provide a valid version 2. **Get current version**: - - Read `diff_diff/__init__.py` and extract the current `__version__` value - - Store as `OLD_VERSION` for comparison link generation + - Read `diff_diff/__init__.py` and note the current `__version__` value — + the "old version" used for comparison-link generation and sanity checks + in the steps below 3. **Compile the changelog and resolve `RELEASE_DATE`** (release notes come exclusively from `changelog.d/` fragments; the old git-log generation step - is removed): + is removed). Shell variables do NOT persist across Bash calls, so + `OLD_VERSION` is resolved inside the SAME block that consumes it (never + substitute it as prose — an empty expansion would fail the compiler's + `--previous-version` cross-check on every normal release): ```bash - python3 .claude/scripts/changelog_compile.py compile --version NEW_VERSION --date "$(date +%F)" + OLD_VERSION="$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' diff_diff/__init__.py)" + python3 .claude/scripts/changelog_compile.py compile --version NEW_VERSION --date "$(date +%F)" --previous-version "$OLD_VERSION" ``` + (Substitute the literal target version for `NEW_VERSION`; it was validated + against the semver pattern in step 1.) + 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 diff --git a/.claude/commands/pre-merge-check.md b/.claude/commands/pre-merge-check.md index 7eba49d4..1ddd572b 100644 --- a/.claude/commands/pre-merge-check.md +++ b/.claude/commands/pre-merge-check.md @@ -53,17 +53,26 @@ every path** — emitting only validated-safe run-lists. It is unit-tested (`tests/test_premerge_scan.py`) with staged *and* untracked `$(touch sentinel)` filenames asserting nothing executes. -Run it: +Run it — as TWO separate Bash calls, so each command's exit status is +inspected on its own (a single block reports only the LAST status, and a +scan exit 3/4 must never be masked by a passing changelog check): ```bash SCRATCH="$(git rev-parse --git-path premerge-scan)"; mkdir -p "$SCRATCH" python3 .claude/scripts/premerge_scan.py --scratch "$SCRATCH" +``` + +Handle the scan's exit per the rules below BEFORE moving on. Then, +separately: + +```bash python3 .claude/scripts/changelog_compile.py check ``` -The second command is the changelog-fragment guard (pointer-only +This second call 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. +here instead of first failing in the label-gated docs-tests CI lane; a +non-zero exit is its own finding to report, independent of the scan. - 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 diff --git a/.claude/commands/push-pr-update.md b/.claude/commands/push-pr-update.md index 75066563..ae372d51 100644 --- a/.claude/commands/push-pr-update.md +++ b/.claude/commands/push-pr-update.md @@ -146,15 +146,41 @@ When the working tree is clean but commits are ahead, scan for secrets in the co When the working tree is clean but commits are ahead, check for methodology issues before pushing: -1. **Methodology review of already-committed changes is deferred to `/pre-merge-check`.** - The changes here are already committed, so this pattern check is non-blocking, and - the pre-merge gate (run before committing) is the right place for it. Do **not** - interpolate a comparison ref into a scan command here — `/pre-merge-check` covers the - working-tree case safely via `premerge_scan.py`, and re-deriving a committed range in - prose is where injection creeps back in. If you want the committed range checked, run - `/pre-merge-check` on the branch before it was committed, or review the diff by eye. - -3. **Documentation impact check**: Check which source files in `diff_diff/` are in the committed changes. +1. **Methodology pattern scan of the committed range** — via the tested argv-safe + helper's `--range` mode (`premerge_scan.py`; the range is passed as DATA in a + quoted variable, never a raw placeholder — the injection shape that got the old + prose-grep version removed). Re-derive the comparison ref inside this one Bash + call using the same fallback chain as Section 2 (shell variables do not persist + across tool calls): + + ```bash + SCRATCH="$(git rev-parse --git-path premerge-scan)"; mkdir -p "$SCRATCH" + DEFAULT_BRANCH="$(gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null || echo main)" + if UP="$(git rev-parse --abbrev-ref @{u} 2>/dev/null)"; then + COMPARISON_REF="$UP" + elif git rev-parse --verify "$DEFAULT_BRANCH" >/dev/null 2>&1; then + COMPARISON_REF="$DEFAULT_BRANCH" + elif git rev-parse --verify "origin/$DEFAULT_BRANCH" >/dev/null 2>&1; then + COMPARISON_REF="origin/$DEFAULT_BRANCH" + else + git fetch origin "$DEFAULT_BRANCH" --depth=1 2>/dev/null || true + COMPARISON_REF="origin/$DEFAULT_BRANCH" + fi + python3 .claude/scripts/premerge_scan.py --scratch "$SCRATCH" --range "$COMPARISON_REF..HEAD" + ``` + + (Upstream-first, exactly Section 2.4's resolver: on an existing PR the scan + covers only the UNPUSHED commits — comparing against the default branch would + rescan previously pushed, already-reviewed changes.) + + Pattern FINDINGS are informational (report file:line; the changes are already + committed). Scan-INTEGRITY failures are not: **exit 3** means a changed path + carries shell metacharacters (excluded from the scan — surface it for manual + review) and **exit 4** means a git/read failure truncated the run-lists — the + scan is incomplete, so report the error rather than describing the range as + clean. + +2. **Documentation impact check**: Check which source files in `diff_diff/` are in the committed changes. If source files are present, read `docs/doc-deps.yaml` and check which dependent documentation files are NOT also in the committed changes. Warn about: - ALL docs with `type: methodology` (regardless of `drift_risk`) @@ -169,7 +195,7 @@ When the working tree is clean but commits are ahead, check for methodology issu `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. +Note: Section 3b FINDINGS are informational warnings only — no AskUserQuestion prompt, since changes are already committed and cannot be unstaged (unlike the staged-changes path, Section 3, which offers a "fix vs continue" choice). The one exception is scan INTEGRITY: `premerge_scan.py` exit 3/4 means the scan itself is incomplete — report that rather than proceeding as if the range were clean. ### 3. Stage and Commit Changes diff --git a/.claude/scripts/changelog_compile.py b/.claude/scripts/changelog_compile.py index 48a3d470..76040f87 100644 --- a/.claude/scripts/changelog_compile.py +++ b/.claude/scripts/changelog_compile.py @@ -55,7 +55,7 @@ "compiled at release by .claude/scripts/changelog_compile.py -->" ) -FRAGMENT_NAME_RE = re.compile(r"^(\d{8})-[a-z0-9][a-z0-9-]*\.md$") +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 @@ -89,6 +89,21 @@ def _parse_fragment(text): current = (cat, []) blocks.append(current) continue + if re.match(r" {1,3}#{1,6}(?:[ \t]|$)", line): + # CommonMark recognizes ATX headings indented up to 3 spaces, so + # an indented '## ...' would slip past the column-zero checks + # above as a "continuation" yet render as a real heading. + errors.append(f"line {lineno}: indented Markdown heading not allowed in a fragment") + continue + if _VERSION_LINK_SHAPE.search(line): + # A '- [1.3.0]:url' bullet would register a CommonMark link + # definition ONCE COMPILED — and the first definition wins, + # outranking the canonical one the compiler appends. + errors.append( + f"line {lineno}: version-link-like construct ('[X.Y.Z]:') " + "not allowed in a fragment" + ) + continue if not line.strip(): if current is not None: current[1].append(line) @@ -193,6 +208,26 @@ def run_check(root): fragments.append(p) _, errors = _parse_fragment(text) findings.extend(f"changelog.d/{p.name}: {e}" for e in errors) + # check↔compile parity: everything the assembled-output guards + # would refuse at release time must already fail HERE, at PR + # time — a fragment that passes CI but blocks the release is a + # fail-late trap. The whole-text scans also cover what the + # line-based grammar cannot see (a multi-line link label, an + # indented fence in a continuation line). + blk = _block_context_violation(text) + if blk: + findings.append( + f"changelog.d/{p.name}: line {blk[0]}: Markdown block " + f"context ({blk[1]!r}) not allowed in a fragment — " + "fenced code / raw-HTML blocks are refused in the " + "compiled changelog; use inline code instead" + ) + link = _noncanonical_version_link(text) + if link: + findings.append( + f"changelog.d/{p.name}: line {link[0]}: version-link-" + f"like construct ({link[1]!r}) not allowed in a fragment" + ) changelog = root / "CHANGELOG.md" if not changelog.is_file(): @@ -236,19 +271,108 @@ def _existing_headers(changelog_text): return out -def _section_nonempty(changelog_text, header_start): +_VERSION_LINK_SHAPE = re.compile(r"\[\s*\d+\.\d+\.\d+\s*\]:") +_CANONICAL_LINK_SHAPE = re.compile(r"\[\d+\.\d+\.\d+\]:") + + +def _noncanonical_version_link(text): + """(lineno, snippet) of the first version-link-LIKE construct that is + not a canonical column-zero '[X.Y.Z]:' definition, else None. + + CommonMark registers reference definitions inside containers (list + items, blockquotes) and whitespace-normalizes labels ('[ 1.3.0 ]' + resolves as '1.3.0'), and the FIRST definition wins — so any + version-label-plus-colon anywhere outside the canonical link block + could silently outrank the definitions this compiler writes and + scans. Rather than chase every container form, refuse them all.""" + for m in _VERSION_LINK_SHAPE.finditer(text): + at_col0 = m.start() == 0 or text[m.start() - 1] == "\n" + if at_col0 and _CANONICAL_LINK_SHAPE.match(text, m.start()): + continue # canonical: exactly what link_versions collects + lineno = text.count("\n", 0, m.start()) + 1 + return lineno, m.group(0).replace("\n", "\\n") + return None + + +# CommonMark block contexts that persist through blank lines (and through +# EOF when unterminated): fenced code (```/~~~ at 0-3 indent) and raw-HTML +# block types 1-5 (
, processing instructions,
+# declarations, CDATA). Any of these could swallow subsequent lines —
+# including the terminal link block — so headers/definitions inside them
+# would not render while still matching the line-based scans. Type-6/7
+# HTML blocks end at the first blank line and cannot reach the terminal
+# block. Comments (type 2) are allowed only when closed on the same line
+# (the Unreleased pointer's form).
+_BLOCK_CONTEXT_STARTER = re.compile(
+    r"^ {0,3}(?:```|~~~|<(?:pre|script|style|textarea)\b|<\?|" not in ln[ln.index("