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
- Pre-push coverage gate — `make push-check` (and the
`.githooks/pre-push` hook that calls it) now runs
`ci/coverage_gate.sh`, which fails if total line coverage drops
below the `coverage_floor:` value in `pubspec.yaml`. The floor
starts at 34% (today's measured floor) and only ratchets up; the
end target is 95% (D-66). `ci/test.sh` now writes
`coverage/lcov.info` as a side effect of the unit/widget/golden
run so the gate adds no extra test invocation.
- Pre-push coverage gate — `make push-check` runs `ci/coverage_gate.sh`,
which fails if total line coverage drops below `coverage_floor:` in
`pubspec.yaml`. Floor ratchets up only; target 95% (D-66).
- Pre-push changelog gate — `ci/changelog_gate.sh` fails on any
`## [Unreleased]` bullet over 60 words; warns at 40. Enforces the
Keep-a-Changelog conciseness rule in the git-commit skill.
- Test sweep — `keybindings`, `toolchain_paths`, and several
`widgets/src/` primitives (tooltip, palette, multitab, markdown).
- `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
- `TerminalView.onTapUp` callbacks now actually fire on a primary
tap. The parameter was wired to a code path that nothing in the
gesture-detector chain ever invoked — the documented
"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.
- `TerminalView.onTapUp` now actually fires on primary tap — was
wired to a dead code path (T-93). Dead `onTapUp` surface on
`TerminalGestureHandler` / `TerminalGestureDetector` removed.
- `BufferLine.eraseRange` no longer panics when called with `end == 0`.
The right-side wide-char guard read `_data[-1]` via `getWidth(-1)`,
which threw a `RangeError`. Real trigger path: `Terminal.eraseDisplayAbove`
with the cursor at column 0 — common after `ESC[H\x1b[1J`
(home + erase-above) sequences that many TUIs emit on redraw.
- Terminal selections no longer silently disappear when the terminal is
resized narrower. Reflow's tail-anchor handler used to reparent
anchors past the source's trimmed-content range onto a builder line
that was never emitted, leaving them detached from the visible
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).
- Terminal selections no longer vanish when resizing narrower —
reflow's tail-anchor handler left anchors detached past the
trimmed range. Common triggers: Ctrl+A then resize, drag past a
partially-filled line (T-92).
- `BufferLine.removeCells` / `insertCells` / `dispose` no longer skip
anchors due to concurrent list modification during iteration —
surfaced by unit tests added under T-91. Anchors disposed inside
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.
iteration now snapshots the list first (T-91).
### 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
is suppressed at the painter level since synthetic bold (with no
Bold.ttf registered) shifts glyph advance widths.
- PTY surfaces errno on `forkpty`, `write`, and `ioctl` failures
instead of swallowing them. `execve` failures in the spawned
child now write a diagnostic line to the slave PTY before
`_exit`, so the parent's reader sees the cause instead of an
immediate EOF that looked indistinguishable from clean exit.
PTY `write` loops on short writes; both `NativePty.write` and
`PtySession.write` now throw `PtyException` on hard errors.
- PTY teardown order fixed — kill the child first so the master
fd returns EOF, await the reader isolate exit, then close the
fd. Previously closing the fd while the isolate still polled it
could briefly target a reused fd. Reader isolate spawn errors
in both `NativePty` and `PtySession` are now surfaced via the
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.
- PTY surfaces errno on `forkpty` / `write` / `ioctl` failures
instead of swallowing. `execve` failures write a diagnostic to
the slave before `_exit`. `NativePty.write` and `PtySession.write`
loop on short writes; both throw `PtyException` on hard errors.
- PTY teardown order fixed — kill child first so the master fd
returns EOF, await reader isolate exit, then close the fd.
Closing earlier could briefly target a reused fd.
- Reader isolate spawn errors in `NativePty` / `PtySession` are now
surfaced via the output stream instead of silently dropped.
`_recvFdAsync` no longer leaks the `ReceivePort` on spawn throw;
`PtySession.spawn` closes the master fd if any post-receive step
fails.
- IPC server hardening: per-request 60s timeout (configurable),
broadcast/response write failures logged instead of swallowed,
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.
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
smoke-bundle: ## Build Linux release bundle and run it under xvfb for 5s.
ci/smoke_bundle.sh
@@ -247,7 +251,7 @@ decisions-validate: ## Parser dry-run over governance/{decisions,questions,rejec
pql decisions validate
.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
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"