add pre-push changelog concision gate (40 soft / 60 hard)

`ci/changelog_gate.sh` walks the `## [Unreleased]` section, measures
each bullet's word count (including indented continuations), and
fails at 60 words. Soft warnings between 40 and 60 don't block.
Wired into `make push-check` so the pre-push hook enforces it.

Trims six pre-existing over-cap bullets in the same commit so the
gate lands green. Released sections are frozen and untouched.

The rule itself lives in .claude/skills/git-commit/SKILL.md
"Be concise"; this gate is the executable companion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 20:26:45 +02:00
co-authored by Claude Opus 4.7
parent b1713f71cf
commit 2cbabea000
3 changed files with 116 additions and 47 deletions
+26 -46
View File
@@ -38,14 +38,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added ### Added
- Pre-push coverage gate — `make push-check` (and the - Pre-push coverage gate — `make push-check` runs `ci/coverage_gate.sh`,
`.githooks/pre-push` hook that calls it) now runs which fails if total line coverage drops below `coverage_floor:` in
`ci/coverage_gate.sh`, which fails if total line coverage drops `pubspec.yaml`. Floor ratchets up only; target 95% (D-66).
below the `coverage_floor:` value in `pubspec.yaml`. The floor - Pre-push changelog gate — `ci/changelog_gate.sh` fails on any
starts at 34% (today's measured floor) and only ratchets up; the `## [Unreleased]` bullet over 60 words; warns at 40. Enforces the
end target is 95% (D-66). `ci/test.sh` now writes Keep-a-Changelog conciseness rule in the git-commit skill.
`coverage/lcov.info` as a side effect of the unit/widget/golden
run so the gate adds no extra test invocation.
- Test sweep — `keybindings`, `toolchain_paths`, and several - Test sweep — `keybindings`, `toolchain_paths`, and several
`widgets/src/` primitives (tooltip, palette, multitab, markdown). `widgets/src/` primitives (tooltip, palette, multitab, markdown).
- `tree_sitter_service` sweep — fake-FFI + real-library smoke, - `tree_sitter_service` sweep — fake-FFI + real-library smoke,
@@ -71,35 +69,21 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Fixed ### Fixed
- `TerminalView.onTapUp` callbacks now actually fire on a primary - `TerminalView.onTapUp` now actually fires on primary tap — was
tap. The parameter was wired to a code path that nothing in the wired to a dead code path (T-93). Dead `onTapUp` surface on
gesture-detector chain ever invoked — the documented `TerminalGestureHandler` / `TerminalGestureDetector` removed.
"Callback for when the user taps on the terminal" was a no-op
for every caller. Routes through `TerminalGestureHandler.onSingleTapUp`
now, with the resolved cell offset (T-93). Dead `onTapUp` surface
on `TerminalGestureHandler` and `TerminalGestureDetector` removed
in the same change.
- `BufferLine.eraseRange` no longer panics when called with `end == 0`. - `BufferLine.eraseRange` no longer panics when called with `end == 0`.
The right-side wide-char guard read `_data[-1]` via `getWidth(-1)`, The right-side wide-char guard read `_data[-1]` via `getWidth(-1)`,
which threw a `RangeError`. Real trigger path: `Terminal.eraseDisplayAbove` which threw a `RangeError`. Real trigger path: `Terminal.eraseDisplayAbove`
with the cursor at column 0 — common after `ESC[H\x1b[1J` with the cursor at column 0 — common after `ESC[H\x1b[1J`
(home + erase-above) sequences that many TUIs emit on redraw. (home + erase-above) sequences that many TUIs emit on redraw.
- Terminal selections no longer silently disappear when the terminal is - Terminal selections no longer vanish when resizing narrower —
resized narrower. Reflow's tail-anchor handler used to reparent reflow's tail-anchor handler left anchors detached past the
anchors past the source's trimmed-content range onto a builder line trimmed range. Common triggers: Ctrl+A then resize, drag past a
that was never emitted, leaving them detached from the visible partially-filled line (T-92).
buffer; the selection controller's `extent.attached` check then
returned null and the highlight vanished. Common trigger paths:
Ctrl+A (select-all) followed by a width change, and mouse drag
selections that extended past the end of a partially-filled line
(T-92).
- `BufferLine.removeCells` / `insertCells` / `dispose` no longer skip - `BufferLine.removeCells` / `insertCells` / `dispose` no longer skip
anchors due to concurrent list modification during iteration — anchors due to concurrent list modification during iteration —
surfaced by unit tests added under T-91. Anchors disposed inside iteration now snapshots the list first (T-91).
the loop were unhooking themselves from the same list the loop was
iterating, causing later anchors to be silently skipped (no
reposition, no dispose) and leaving the buffer in an inconsistent
state. Iteration now snapshots the list first.
### Changed ### Changed
@@ -143,22 +127,18 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
- Terminal cell grid no longer drifts on bold text — bold rendering - Terminal cell grid no longer drifts on bold text — bold rendering
is suppressed at the painter level since synthetic bold (with no is suppressed at the painter level since synthetic bold (with no
Bold.ttf registered) shifts glyph advance widths. Bold.ttf registered) shifts glyph advance widths.
- PTY surfaces errno on `forkpty`, `write`, and `ioctl` failures - PTY surfaces errno on `forkpty` / `write` / `ioctl` failures
instead of swallowing them. `execve` failures in the spawned instead of swallowing. `execve` failures write a diagnostic to
child now write a diagnostic line to the slave PTY before the slave before `_exit`. `NativePty.write` and `PtySession.write`
`_exit`, so the parent's reader sees the cause instead of an loop on short writes; both throw `PtyException` on hard errors.
immediate EOF that looked indistinguishable from clean exit. - PTY teardown order fixed — kill child first so the master fd
PTY `write` loops on short writes; both `NativePty.write` and returns EOF, await reader isolate exit, then close the fd.
`PtySession.write` now throw `PtyException` on hard errors. Closing earlier could briefly target a reused fd.
- PTY teardown order fixed — kill the child first so the master - Reader isolate spawn errors in `NativePty` / `PtySession` are now
fd returns EOF, await the reader isolate exit, then close the surfaced via the output stream instead of silently dropped.
fd. Previously closing the fd while the isolate still polled it `_recvFdAsync` no longer leaks the `ReceivePort` on spawn throw;
could briefly target a reused fd. Reader isolate spawn errors `PtySession.spawn` closes the master fd if any post-receive step
in both `NativePty` and `PtySession` are now surfaced via the fails.
output stream instead of silently dropped. `_recvFdAsync` no
longer leaks the `ReceivePort` when `Isolate.spawn` throws, and
`PtySession.spawn` closes the master fd if any post-receive
step fails.
- IPC server hardening: per-request 60s timeout (configurable), - IPC server hardening: per-request 60s timeout (configurable),
broadcast/response write failures logged instead of swallowed, broadcast/response write failures logged instead of swallowed,
client dropped on response-write failure, and the stale-socket client dropped on response-write failure, and the stale-socket
+5 -1
View File
@@ -106,6 +106,10 @@ test-all: test-core test test-a11y test-integration test-e2e ## Everything, sequ
coverage-gate: ## Coverage gate — fails if total line % < pubspec.yaml `coverage_floor:` (D-66). Assumes `make test` ran first. coverage-gate: ## Coverage gate — fails if total line % < pubspec.yaml `coverage_floor:` (D-66). Assumes `make test` ran first.
ci/coverage_gate.sh ci/coverage_gate.sh
.PHONY: changelog-gate
changelog-gate: ## Changelog concision gate — fails on `## [Unreleased]` bullets over 60 words.
ci/changelog_gate.sh
.PHONY: smoke-bundle .PHONY: smoke-bundle
smoke-bundle: ## Build Linux release bundle and run it under xvfb for 5s. smoke-bundle: ## Build Linux release bundle and run it under xvfb for 5s.
ci/smoke_bundle.sh ci/smoke_bundle.sh
@@ -247,7 +251,7 @@ decisions-validate: ## Parser dry-run over governance/{decisions,questions,rejec
pql decisions validate pql decisions validate
.PHONY: push-check .PHONY: push-check
push-check: decisions-validate test-core test test-a11y coverage-gate ## Pre-push gate. push-check: decisions-validate test-core test test-a11y coverage-gate changelog-gate ## Pre-push gate.
.PHONY: hooks .PHONY: hooks
hooks: ## Install the repo's git hooks. hooks: ## Install the repo's git hooks.
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# CHANGELOG concision gate — enforces the per-bullet word caps from the
# git-commit skill (40 soft, 60 hard) across the `## [Unreleased]`
# section. Released sections are frozen and skipped (don't penalize
# historical entries pre-dating the rule).
#
# A "bullet" is a markdown list item beginning with `- `, including any
# indented continuation lines until the next bullet, blank line, or
# heading. Word count is whitespace-tokenized.
#
# Soft cap 40 → warn (non-zero exit only if HARD is also breached).
# Hard cap 60 → fail.
#
# Bypass: never. If the rule rejects something genuinely user-visible
# that needs more context, the context belongs in the commit body or a
# D-record — see .claude/skills/git-commit/SKILL.md.
set -euo pipefail
cd "$(dirname "$0")/.."
CHANGELOG=CHANGELOG.md
SOFT_CAP=40
HARD_CAP=60
if [[ ! -f "$CHANGELOG" ]]; then
echo "==> changelog gate: $CHANGELOG missing" >&2
exit 2
fi
awk -v soft="$SOFT_CAP" -v hard="$HARD_CAP" '
BEGIN { in_unreleased = 0; bullet = ""; bullet_start = 0; fail = 0; warn = 0 }
function check_bullet() {
if (bullet == "") return
n = split(bullet, _words, /[[:space:]]+/)
# split() counts a trailing empty token when the string starts/ends with
# whitespace; trim the leading "- " marker too.
gsub(/^- +/, "", bullet)
n = split(bullet, _words, /[[:space:]]+/)
if (n > hard) {
printf "FAIL line %d: bullet is %d words (hard cap %d)\n", bullet_start, n, hard
printf " %s\n\n", substr(bullet, 1, 120) (length(bullet) > 120 ? "..." : "")
fail++
} else if (n > soft) {
printf "WARN line %d: bullet is %d words (soft cap %d)\n", bullet_start, n, soft
printf " %s\n\n", substr(bullet, 1, 120) (length(bullet) > 120 ? "..." : "")
warn++
}
bullet = ""
bullet_start = 0
}
/^## \[Unreleased\]/ { in_unreleased = 1; next }
/^## \[/ && in_unreleased { check_bullet(); in_unreleased = 0; exit_loop = 1 }
!in_unreleased { next }
# Within Unreleased.
/^### / { check_bullet(); next } # subsection header
/^[[:space:]]*$/ { check_bullet(); next } # blank line ends bullet
/^- / { # new bullet
check_bullet()
bullet = $0
bullet_start = NR
next
}
/^[[:space:]]+/ && bullet != "" { # continuation of current bullet
bullet = bullet " " $0
next
}
END {
check_bullet()
if (fail > 0) {
printf "\n==> changelog gate FAIL: %d bullet(s) over %d words.\n", fail, hard
printf " Trim them. Rationale, probe results, behavior-change deep dives\n"
printf " belong in the commit body, a D-record, or the ticket — not here.\n"
printf " See .claude/skills/git-commit/SKILL.md \"Be concise\".\n"
exit 1
}
if (warn > 0) {
printf "==> changelog gate OK with %d warning(s) over %d words.\n", warn, soft
} else {
printf "==> changelog gate OK\n"
}
}
' "$CHANGELOG"