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:
+26
-46
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Executable
+85
@@ -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"
|
||||
Reference in New Issue
Block a user