feat(hooks): add commit-msg + CI issue-citation gate - #1441
Conversation
Branch Plan and ACM re-verification record for the commit-msg hook + CI backstop citation-check feature. Refs #1212
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1441 +/- ##
========================================
Coverage 99.63% 99.63%
========================================
Files 147 148 +1
Lines 25412 25561 +149
Branches 3133 3151 +18
========================================
+ Hits 25318 25467 +149
Misses 94 94 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Adds a commit-msg pre-commit hook (.pre-commit-config.yaml's commit-citation entry) plus a CI backstop (.github/workflows/commit-citation-gate.yml, registered in .gitapex/ssot.json as commit-citation-gate) enforcing CLAUDE.md section 3's issue-citation rule for commits. The CI check passes when a citation exists in any non-merge commit in the PR's own range (git log --no-merges base..head) or in the PR title/body; an uncited merge commit in that range never fails it alone. Both --mode commit-msg and --mode pr-range reuse extract_citations from hooks/gitapex_check_pr_issue_acm_disclosure.py rather than a new regex, mirroring gitapex_run_betterleaks.py's one-script/two-mode shape. Adds -t commit-msg to every `prek install` invocation so the local hook actually gets installed on all three onboarding paths found by grep: CONTRIBUTING.md, flake.nix's devShell, and .claude/hooks/session-start.sh's own ephemeral-web install (the last one not named in the original design note, added for the same reason as the other two). Updates the local-preflight wired-gate-count prose (39 -> 40, and the CI-backed subset 37 -> 38) this new ci+local registry entry now affects, in every file test_no_prose_count_contradicts_the_registry checks. Refs #1212
Two gates flagged the new gitapex_gate_commit_citation.py once it
entered a real diff against origin/main:
- stdlib-only-claim-drift: the sys.path bootstrap comment's own prose
example ("a standalone `python3 .../gitapex_gate_commit_citation.py`
invocation") read as a bare-invocation claim once the file gained a
real pydantic import; reworded to name `uv run --frozen python3`
instead, matching every other .github/scripts/*.py file's own
invocation.
- detection-logic-property-coverage: REPO_ROOT/the hooks/ sys.path
bootstrap's two module-level .resolve() calls had no co-located
Hypothesis @given property test. Adds
tests/test_gitapex_gate_commit_citation_properties.py covering
check_commit_message/check_pr_text's own citation-form recognition,
fence containment, and robustness (mirrors
tests/test_gitapex_check_pr_duplicate_issue_properties.py's own
shape), which also clears the module-level finding per that gate's
own documented rule.
Refs #1212
…ready-om26qe # Conflicts: # .gitapex/ssot.json # .github/scripts/gitapex_gate_local_preflight.py # .pre-commit-config.yaml # CONTRIBUTING.md
Independent reuse/simplify pass over the commit-citation gate. Two findings, both behavior-preserving on every real input (verified by a 20-case differential run of the pre-change file against this one: identical exit code, stdout and stderr in every case except the two noted below): - commit_range_messages hand-rolled the same subprocess.run _gitapex_base_ref.run_git already provides (same capture_output/ text/errors="replace"/check=False shape), duplicating it with two noqa suppressions and without the timeout/OSError handling every other git call in this module gets. Now delegates to run_git, so a missing git or a hang past GIT_TIMEOUT_SECONDS raises CitationGateError -- the documented exit-2 "could not be trusted" path -- instead of escaping as an uncaught traceback whose exit 1 is indistinguishable from a genuine no-citation FAIL. Drops the now-unused subprocess import. - The same FileNotFoundError/UnicodeDecodeError handler pair was written three times (once inline in _run_commit_msg, twice via _read_optional_file's (text, error) return). _read_input_file now raises CitationGateError, which both _run_commit_msg and _run_pr_range already catch, so the two error-check blocks in _run_pr_range fold into the existing try and the tuple-return idiom (used nowhere else in .github/scripts) is gone. Message text and exit codes are unchanged. The only observable differences: --mode commit-msg's not-found message now echoes the path as given rather than pathlib-normalized (so "./x.txt" stays "./x.txt", matching what --mode pr-range's own message already did); prek passes .git/COMMIT_EDITMSG, which normalizes identically. Exit code and text are otherwise byte-identical. Net -12 executable lines; the +8 total-line growth is the in-file rationale this repository's own .github/scripts convention expects, including why REPO_ROOT cannot be hoisted above the sys.path bootstrap to share its expression (ruff E402 rejects it -- verified, not assumed). Verification: 154 passed for the three affected test files, 6744 passed for the full suite, gitapex_gate_commit_citation.py at 100% statement and branch coverage, ruff check / ruff format --check / mypy clean, and 40 of 41 local-preflight gates PASS (behind-base fails only because this branch sits behind origin/main, unrelated to this change). Refs #1212
…ready-om26qe # Conflicts: # .github/scripts/gitapex_gate_local_preflight.py # .pre-commit-config.yaml # CONTRIBUTING.md
Refs #1212 Independent adversarial review (executing-a-branch-plan Decision 12) of the two-layer commit issue-citation gate. Every finding below was live-reproduced against real git before being fixed, and each carries its own regression test proving the defeat case now behaves correctly while the legitimate FAIL it could be confused with still fails. 1. --mode commit-msg false-PASSed an uncited commit. A commit-msg hook runs before git's own --cleanup pass, so the file it receives still carries git's comment block and, under commit.verbose, the whole staged diff below the scissors line. Staging a file whose content contained "# See issue #1212 for the rationale." and committing the uncited subject "chore: tidy up formatting" returned PASS for a commit whose stored %B cited nothing. Now cleaned first: scissors truncation, then git stripspace --strip-comments (git's own implementation, so core.commentChar is resolved rather than guessed -- verified against a core.commentChar=';' repo). A stripspace that cannot run is exit 2, never a silent fallback to the uncleaned text. --mode pr-range is deliberately unchanged: git log --format=%B is already clean. 2. --mode pr-range FAILed when there was nothing to check. ssot.json's local_invocation passes no --title/--body, and feeds the pre-push local-preflight, which reads any non-zero exit as a blocked push. An empty origin/main..HEAD range exited 1, blocking a push over a state that has no citation obligation. That shape now passes with an explicit "nothing to check" message. The flag tracks whether --title and --body were passed, never whether their text is empty, so CI (which always passes both) is unreachable from this path. 3. commit_range_messages dropped empty-message commits, which combined with finding 2 to make two real --allow-empty-message commits indistinguishable from an empty range -- uncited commits would have passed as though absent. The NUL split now drops only its own trailing separator artifact, one entry per commit. 4. The commit-msg layer rejected every merge commit, contradicting this gate's own stated non-goal that --mode pr-range already honors via git log --no-merges. Live-reproduced: an ordinary git merge --no-ff was rejected and left git mid-merge, which would have broken the documented git pull --no-rebase shared-branch workflow on every merge -- and session-start.sh now installs this hook automatically, so nobody had to opt in to hit it. Merges are now exempt via MERGE_HEAD (never the message filename: a git commit completing a merge is the same commit and must be exempt too). git merge --squash stays gated, matching what CI would scan. 5. Fail-closed input handling (evaluating-deterministic-gate-quality dimension 15): _read_input_file caught only FileNotFoundError and UnicodeDecodeError, so pointing any path flag at a directory or an unreadable file escaped as an uncaught traceback whose exit code is 1 -- the code this module reserves for a confirmed policy FAIL, so a broken invocation reported itself as a real citation violation. Now exit 2 with a distinct message. Also fixes a latent flake in this branch's own new property suite: the filler alphabet excluded backticks but not tildes, so Hypothesis could generate a genuine ~~~ fence around the injected citation and fail the recognition property against wholly correct behavior. run_git gains an optional stdin_text for git stripspace, rather than a second near-identical subprocess wrapper in the caller; the default None leaves every pre-existing caller's behavior untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VEw8Lr4CL7Bbdx3YuXv83V
|
CI red not caused by this PR's diff: This test exercises the Re-ran the failed job once ( Generated by Claude Code |
…ready-om26qe # Conflicts: # .gitapex/ssot.json # .github/scripts/gitapex_gate_local_preflight.py # .pre-commit-config.yaml # CONTRIBUTING.md # tests/test_gitapex_gate_local_preflight.py
…pendent review
- .pre-commit-config.yaml's own "Setup:" comment was the one place in
the repo missed when -t commit-msg was added to every other
`prek install` example (CONTRIBUTING.md, flake.nix,
.claude/hooks/session-start.sh already had it).
- extract_citations() raises an uncaught ValueError for a citation-shaped
digit run beyond Python's int-string-conversion limit (4300 digits),
live-reproduced; escaped as exit 1 (this module's own "confirmed FAIL"
code) instead of exit 2 ("check could not be trusted"). Wrapped with a
CitationGateError conversion and regression tests for both the direct
call and both --mode entry points.
Refs #1212
Summary
Adds a two-layer issue-citation gate for commits: a
commit-msggit hook(fast local first pass) and a CI check (the actual no-exceptions backstop),
per CLAUDE.md section 3's "cite the issue number in every commit" rule.
One script, two
--modevalues, reusingextract_citations()fromhooks/gitapex_check_pr_issue_acm_disclosure.pyrather than a new regex.Facts
PR; cite its number in every commit and PR. No exceptions."
PreToolUse hook); no
commit-msghook and no CI gate scanned individualcommit messages.
extract_citations()(hooks/gitapex_check_pr_issue_acm_disclosure.py)already recognizes GitHub's real closing-keyword set case-insensitively,
strips fenced/inline code first, and normalizes a same-repo
owner/repo#Nto#N-- reused here rather than re-implemented..pre-commit-config.yaml's established one-script/two---modeconvention (
gitapex_run_betterleaks.py --mode staged/--mode history)is mirrored by the new
gitapex_gate_commit_citation.py(
--mode commit-msg/--mode pr-range).Assumptions
resolution mechanism for
--mode pr-rangefollows this repo's existingCI-script convention for reading PR range/metadata.
Risk / blast radius
commit-msghook: bypassable viagit commit --no-verify; onlyfires where
prek install -t commit-msghas been run. Not itselfsufficient for "no exceptions" -- the CI check is the actual backstop.
non-merge commit), deliberately weaker than a strict per-commit check,
by design (robust to no-rebase/squash-merge redistribution destinations
per the issue's own Constraints).
.github/scripts/intohooks/--extract_citationsand its private helpers are pure andnetwork-free, so no new side effect is introduced.
citation format (tracked separately, feat(ci): add CI-config gates -- eval-job timeout sizing and PR/issue-body citation format #521) -- both explicit Non-goals.
review (neither weakens the gate; see
## Skill audit evidencebelow):resolve_base_ref's probe/fetch/re-probe sequence duplicatesgitapex_run_base_diff.ensure_base_refalmost verbatim (a cross-filereuse issue, out of scope for this PR -- unifying touches two other
files and their tests); and the same-repo
owner/repo#Nnormalizationcheck_pr_textapplies is not applied tocheck_commit_message's ownper-commit citation checks -- both
--mode commit-msg(the local hook)and
--mode pr-range's own per-commit scan loop share this (correctedduring
drafting-a-pr-to-merge's own independent review below: theoriginal text of this bullet understated the CI backstop's own exposure
to it). A same-repo-qualified citation living only in one commit
message, with no citation in the PR title/body and no other commit
carrying one, therefore reads as uncited. Still fails closed only (a
false negative, never a false positive): the bare
#N/Closes #Nformsthis repo's own
CONTRIBUTING.mdactually documents are unaffected, andany citation reaching the PR title/body still clears the CI backstop
regardless.
origin/maindrift merge:
run_git's newstdin_textparameter (added to unblock thegit stripspacefix below) is not "named" bygitapex_gate_function_body_test_coverage.py(issue gate-proposal: retro #1492 repair 11: Missing regression test for the basename-collision fix #1498) even thoughit is genuinely covered by pre-existing tests in
tests/test_gitapex_base_ref.py-- that gate's own stem computationkeeps
_gitapex_base_ref.py's leading underscore(
tests/test__gitapex_base_ref.py, double underscore), which does notmatch this repository's own actual, established single-underscore
test-file convention for a
_-prefixed private helper module. Disclosedinline as
# function-body-test-coverage: WAIVED: ...onrun_gititself, not a real coverage gap.
Rollback
Revert this PR's commits. No schema migration, no data change; removing
the
.pre-commit-config.yamlhook entry and the new CI workflow stepfully disables both layers with no other cleanup required.
Verification
commit-msghook exists as a fast local first pass--no-mergesposture)pytest tests/test_gitapex_gate_commit_citation*.py-- 81 passed, 100% line+branch coverage on the new scriptpytest -q-- 7920 passed, no regressions;ruff check/ruff format --check/mypy clean;gitapex_gate_local_preflight.py-- 44/44 wired gates pass includingcommit-citation-gateandfunction-body-test-coverageChecklist
CONTRIBUTING.md)skills/*/SKILL.md, adocs/superpowers/specs/*.mddesign doc, a security-relevant skill, or a deterministic checker script (skills/*/scripts/*.py,evals/scripts/*.py,.github/scripts/*.py), a## Skill audit evidencesection discloses the required verdicts/waivers (see.github/scripts/gitapex_gate_skill_audit_disclosure.py)evals/*/split.md, that entry discloses a Transfer check line (see.github/scripts/gitapex_gate_transfer_check_disclosure.py)skills/*/SKILL.md's Stop-boundary bullets or named dispatch branches,evals//tasks/*.yamlgained at least as many new fixtures (see.github/scripts/gitapex_gate_skill_branch_fixture_coverage.py)Skill audit evidence
This PR adds a new deterministic checker script,
.github/scripts/gitapex_gate_commit_citation.py, registered as a gate in.gitapex/ssot.json(commit-citation-gate), and touches.github/scripts/gitapex_gate_local_preflight.pyand.github/scripts/_gitapex_base_ref.py(an additive, backward-compatiblestdin_textparameter on the sharedrun_githelper, needed for thegit stripspacefix below).checker-script-adversarial-review: RAN -- an independent fresh-context adversarial review pass (distinct from the implementer) found and fixed 5 real correctness defects: (A)--mode commit-msgcould false-PASS on a citation appearing only in git's own pre-cleanup comment block or staged-diff-below-scissors content, never in the actual stored commit message (live-reproduced against a real git hook invocation; fixed viagit stripspace --strip-commentsplus scissors truncation, fail-closed to exit 2 ifstripspaceitself is unavailable); (B) the local--mode pr-rangeinvocation (used bylocal-preflight) FAILed on a genuinely empty commit range with no PR title/body supplied, reading "nothing to check" as a policy violation; (C) the commit-msg hook rejected every merge commit, inconsistent with the CI layer's own documented--no-mergesexemption, and would have brokengit pull --no-rebasefor any session that installed it via this PR's own.claude/hooks/session-start.shchange; (D) a self-introduced regression during fix (B) that would have dropped genuinely uncited empty-message commits from the scan; (E) an uncaught-traceback fail-open path (exit 1, indistinguishable from a real policy FAIL) on an unreadable/directory--title/--bodypath. All five are fixed with regression tests.deterministic-gate-quality: RAN -- read againstskills/evaluating-deterministic-gate-quality/references/dimensions.md, dimension 15 (fail-closed default on malformed input) in particular. Constructed cases: non-UTF-8 commit-msg file, a directory passed as--title/--body, an unreadable file, a non-git--root, a nonexistent--root, an invalid--mode,git stripspaceunavailable, and (found indrafting-a-pr-to-merge's own later independent review, below) a citation-shaped digit run beyond Python's int-string-conversion limit -- all now exit 2 (the module's own "check could not be trusted" convention), none silently pass or throw an uncaught traceback.defeat-test-disclosure: RAN -- every finding above carries a regression test specifically constructed to defeat the prior detection logic (confirmed to actually have teeth against a mental revert), committed alongside each fix rather than run once and discarded.Two findings noted but intentionally not fixed in this PR (both disclosed above under Risk/blast radius, both fail-closed-only, tracked as follow-ups): the
resolve_base_ref/ensure_base_refcross-file duplication, andgitapex_gate_function_body_test_coverage.py's own leading-underscore stem-naming mismatch (a limitation in that gate itself, not in this PR's diff).Merge gate: independent review
This PR is also subject to the
independent-review-pendingrequiredstatus check (see
.github/workflows/independent-review-pending.yml/.github/scripts/gitapex_gate_independent_review_pending.py). It stayspending/failing until a
## Independent review verdictsection namingthis PR's current head commit is recorded in this body --
drafting-a-pr-to-merge's own Step 8 records it once its independentreview completes. There is nothing for you to do here now: do not
pre-fill this section yourself, and do not remove this note.
Independent review verdict
Outer layer (GitHub-native reviewer): no confirmation that Anthropic's
"Claude Code Review" GitHub App is installed on this repository, so
GitHub Copilot review was requested instead
(
request_copilot_review) at approximately 2026-08-30T16:20Z. Noresponse was posted within 30+ minutes of the request (re-checked via a
fresh
pull_request_read--get_reviewsstill empty) -> treated asunreachable for this step and disclosed as such, per this skill's own
"neither mechanism is configured or reachable, record that this layer
did not run at all" rule -- not silently equated with a clean pass.
Inner layer (
reviewing-an-artifact, effort: low): Step 0 -- mixedtarget (a new deterministic gate script alongside its tests, CI workflow,
and doc edits); not deferred wholesale to
evaluating-deterministic-gate-qualitysince dimension 15 of that rubric was already substantively applied
during branch execution (see
deterministic-gate-quality: RANabove) --reviewed via the standard 5-persona fan-out instead, each dispatch a
fresh, isolated
Agent-tool call with no memory of this session's ownauthoring, PR-description/commit-message metadata redacted from every
non-intent-consistency persona's own prompt (this run carried no
high-effort intent-consistency persona). Step 1: classified dangerous(a new enforcement gate, non-safe-side). Step 2: correctness,
blast-radius, reuse-and-simplification, convention, and security
reviewers dispatched in parallel against the full PR diff.
2
confirmedfindings, both fixed (commits17c6c62c,b0086116),each independently re-verified by a sixth, dedicated fresh-context
re-review pass of just the fix diff (clean -- no new defect, both fixes
confirmed to actually close their own target defect against a mental
revert):
.pre-commit-config.yaml's own "Setup:" comment was theone place
-t commit-msgwas missing after this PR's own sweep ofevery other occurrence in the repo.
evaluating-deterministic-gate-quality,CWE-248-adjacent):
extract_citations()raised an uncaughtValueErrorfor a citation-shaped digit run beyond Python'sint-string-conversion limit (live-reproduced:
int('9'*5000)raises),escaping as exit 1 -- this module's own code for a confirmed FAIL --
instead of exit 2. Fixed with a
CitationGateError-converting wrapperplus regression tests at both
--modeentry points.1
confirmedfinding, disclosure-correction only (no code change --see the corrected Risk/blast-radius bullet above): the
owner/repo#Nsame-repo-normalization asymmetry reaches
--mode pr-range's ownper-commit scan loop too, not only the local
--mode commit-msghook asthe PR body previously stated. Still fails closed only; the PR's own
title/body path is unaffected and remains the CI backstop's own primary
defense.
1
unconfirmed-concern(security-tier per Step 4's own unconditionalrule, reported despite not clearing the low-effort 0.7 confidence bar):
a narrow TOCTOU window where a citation satisfied only via the PR
title/body, later edited to remove it, may leave a stale "success"
status momentarily on the same head SHA. Assessed as a generic property
of any SHA-keyed GitHub required check rather than a defect this diff's
own logic introduces; no code-execution or secrets-exposure impact.
1 candidate raised and dropped (below the 0.7 bar, not security-tier,
per this skill's own "a finding below the bar is preferable to lose
than a false positive is to report"): a fourth near-duplicate copy of
the "write untrusted PR text to a file" workflow-step idiom -- this PR
follows, not introduces, that existing repo-wide convention; a
composite-action extraction is a reasonable follow-up, out of this PR's
own scope.
No other findings survived verification across all five personas or the
dedicated fix-diff re-review.
Acceptance Criteria Map
commit-msghook exists as a fast local first pass, in the repository's own dev-only tooling (not the deployed plugin surface).github/scripts/gitapex_gate_commit_citation.py, mirroringgitapex_run_betterleaks.py's one-script/two---modeshape.--mode commit-msgreads the message file path fromsys.argv[1], importsextract_citationsfromhooks/gitapex_check_pr_issue_acm_disclosure.py, passes when it returns a non-emptyresolvingorcontexttuple. Wired into.pre-commit-config.yaml,stages: [commit-msg]--no-verify;CONTRIBUTING.md/flake.nixprek installinvocations updated with-t commit-msg--mode pr-range, invoked from a CI workflow step; registered in.gitapex/ssot.jsonExecution log
PlanApproved{run_id: 693e64a}TaskStarted{run_id: 693e64a, task_id: task-1}TaskCompleted{run_id: 693e64a, task_id: task-1, commit_sha: ec6073d0}(task diff at 97da2d2, merged onto the shared branch, which also brings in an origin/main merge resolving a conflict against origin/main's own concurrent split-disclosure-gate addition)061ce1be-- reuserun_git, collapse duplicated file-read error handling; behavior-preserving, differential-proof-verified866bb885-- 5 confirmed findings fixed, regression tests added; see## Skill audit evidenceaboveorigin/maindrift merge (c52c6f5a) applied after the refactor pass, re-verified with the full localpytest -qsuite andgitapex_gate_local_preflight.pyorigin/maindrift merge (b0d9d659) applied after the adversarial-review pass, same re-verificationmergeable_state: "dirty"(250 commits oforigin/maindrift accumulated). Merged (d1086b81), resolving conflicts in.gitapex/ssot.json/gitapex_gate_local_preflight.py/.pre-commit-config.yaml/CONTRIBUTING.md/tests/test_gitapex_gate_local_preflight.pyby keeping both sides' additions and recomputing real gate-count/timing prose; adoptedorigin/main's own issue fix(hooks): local-preflight pre-push hook crashes when system python3 lacks jsonschema #1485 fix (the local-preflight runner itself now invoked viauv run --frozen python3, not barepython3, since it importsjsonschema)origin/main-added gate,function-body-test-coverage(issue gate-proposal: retro #1492 repair 11: Missing regression test for the basename-collision fix #1498), flagging 6 touched functions with no diff-added test mentioning them by name; closed withac058caa(direct-call tests for 5 functions ingitapex_gate_commit_citation.py) plus a disclosed WAIVED comment forrun_git(see Risk/blast radius above) -- re-verified: fullpytest -q7917 passed,gitapex_gate_local_preflight.py44/44 wired gates pass1776765e): local-preflight gate-count and timing text updated to the real, current 44-gate figuredrafting-a-pr-to-mergeStep 8 (this skill's own independent review, distinct from branch execution's own two passes above): re-verifiedmergeable_statebefore running (onlyindependent-review-pendingoutstanding), a secondorigin/maindrift merge (b008611's own predecessor commits) was not needed at this point (0 commits behind at the time of this review). 5-persona fan-out found 2 confirmed findings, fixed in17c6c62c/b0086116(see## Skill audit evidence), re-verified via a sixth focused re-review of the fix diff -- clean. This verdict recorded above.Related Issue
Closes #1212