Skip to content

Commit cb5c0eb

Browse files
committed
chore(wheelhouse): cascade template@cdbd0dd5
Auto-applied by socket-wheelhouse sync-scaffolding into cascade-socket-cli-96149. 20 file(s) touched: - .claude/hooks/fleet/changelog-no-empty-sections-guard/README.md - .claude/hooks/fleet/changelog-no-empty-sections-guard/index.mts - .claude/hooks/fleet/changelog-no-empty-sections-guard/package.json - .claude/hooks/fleet/changelog-no-empty-sections-guard/test/index.test.mts - .claude/hooks/fleet/changelog-no-empty-sections-guard/tsconfig.json - .claude/hooks/fleet/setup-security-tools/external-tools.json - .claude/settings.json - .config/.markdownlint-cli2.jsonc - .config/markdownlint-rules/_shared/wheelhouse-self-skip.mts - .config/markdownlint-rules/socket-no-empty-changelog-sections.mts - .config/markdownlint-rules/socket-no-private-wheelhouse-leak.mts - .config/markdownlint-rules/socket-no-relative-sibling-script.mts - .config/markdownlint-rules/socket-readme-required-sections.mts - .config/oxfmtrc.json - .config/oxlintrc.json - .gitattributes - CLAUDE.md - docs/claude.md/fleet/version-bumps.md - package.json - pnpm-workspace.yaml
1 parent a97ae90 commit cb5c0eb

21 files changed

Lines changed: 1188 additions & 39 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# changelog-no-empty-sections-guard
2+
3+
PreToolUse hook on `Edit` / `Write` against `CHANGELOG.md`. Blocks the operation if the post-edit content would leave a Keep-a-Changelog section (`### Added`, `### Changed`, `### Deprecated`, `### Fixed`, `### Migration`, `### Performance`, `### Removed`, `### Renamed`, `### Security`) with zero bullets before the next heading.
4+
5+
## Why
6+
7+
The `docs/claude.md/fleet/version-bumps.md` §2 rule (CHANGELOG public-facing only) tells the author to filter internal commits out. When the filter happens to leave a section empty, the heading should be deleted too. Leaving an empty heading makes the reader disambiguate "section intentionally empty" from "section forgot its content."
8+
9+
## What counts as empty
10+
11+
The hook walks the post-edit content line by line. A `### <SectionName>` heading is flagged when its next non-blank line is either:
12+
13+
- Another `### ` heading
14+
- A `## [` version heading
15+
- End of file
16+
17+
Blank lines between the heading and the next heading don't count — only "no actual bullets in the section."
18+
19+
## Bypass
20+
21+
Type `Allow changelog-empty-section bypass` verbatim in a recent user message. The hook scans the last 8 user turns of the transcript.
22+
23+
Bypass is for rare cases where the author deliberately wants an empty heading (e.g. cherry-picking a release skeleton). Default policy is to delete the heading.
24+
25+
## What it does NOT do
26+
27+
- It does not check for sections OUTSIDE the Keep-a-Changelog schema (custom `### Internal` etc.).
28+
- It does not enforce ordering of sections within a release.
29+
- It does not enforce that the section bullets are well-formed (no `- ` prefix check).
30+
31+
## Tests
32+
33+
`pnpm exec node --test test/*.test.mts`
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
#!/usr/bin/env node
2+
// Claude Code PreToolUse hook — changelog-no-empty-sections-guard.
3+
//
4+
// Blocks Edit/Write tool calls that would land an empty
5+
// `### <Keep-a-Changelog-section>` heading in `CHANGELOG.md`.
6+
//
7+
// Why: the version-bumps rule ("CHANGELOG public-facing only") tells
8+
// the author to FILTER out internal commits. When the filter happens
9+
// to leave a Keep-a-Changelog section (Added / Changed / Removed /
10+
// Renamed / Fixed / Performance / Migration) with zero bullets, the
11+
// heading should be deleted too. Leaving an empty heading makes the
12+
// reader disambiguate "section intentionally empty" from "section
13+
// forgot its content" — every release should communicate clearly.
14+
//
15+
// What counts as empty: a `### Section` line whose immediate next
16+
// non-blank line is another heading (`### Section` / `## [`) — i.e.
17+
// the section has no bullets before the next heading. Comments and
18+
// blank lines between the heading and the next heading don't count.
19+
//
20+
// Bypass: type `Allow changelog-empty-section bypass` in a recent
21+
// user turn. The hook reads the recent transcript for the phrase.
22+
23+
import { existsSync, readFileSync } from 'node:fs'
24+
import path from 'node:path'
25+
import process from 'node:process'
26+
27+
import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts'
28+
29+
const BYPASS_PHRASE = 'Allow changelog-empty-section bypass'
30+
const BYPASS_LOOKBACK_USER_TURNS = 8
31+
32+
/**
33+
* Keep-a-Changelog headings the rule recognizes. Custom subsection
34+
* names (e.g. `### Internal`) outside this set are left alone — the
35+
* rule's job is to keep the consumer-facing schema clean, not to
36+
* police every heading shape downstream chooses.
37+
*/
38+
const SECTION_NAMES = new Set([
39+
'Added',
40+
'Changed',
41+
'Deprecated',
42+
'Fixed',
43+
'Migration',
44+
'Performance',
45+
'Removed',
46+
'Renamed',
47+
'Security',
48+
])
49+
50+
/**
51+
* Find empty Keep-a-Changelog sections in CHANGELOG.md content.
52+
* Returns an array of { line, name } for each empty `### Section`
53+
* heading. A section is empty when the next non-blank line is either
54+
* another `### ` heading, another `## [` version heading, or EOF.
55+
*/
56+
export function findEmptySections(
57+
content: string,
58+
): Array<{ line: number; name: string }> {
59+
const lines = content.split('\n')
60+
const empty: Array<{ line: number; name: string }> = []
61+
for (let i = 0; i < lines.length; i++) {
62+
const line = lines[i]!
63+
if (!line.startsWith('### ')) {
64+
continue
65+
}
66+
const name = line.slice(4).trim()
67+
if (!SECTION_NAMES.has(name)) {
68+
continue
69+
}
70+
// Scan forward for the next non-blank line.
71+
let nextNonBlank: string | undefined
72+
for (let j = i + 1; j < lines.length; j++) {
73+
const next = lines[j]!
74+
if (next.trim() === '') {
75+
continue
76+
}
77+
nextNonBlank = next
78+
break
79+
}
80+
// Empty if next non-blank is a heading at the same or higher
81+
// level, or end-of-file.
82+
if (
83+
nextNonBlank === undefined ||
84+
nextNonBlank.startsWith('### ') ||
85+
nextNonBlank.startsWith('## ')
86+
) {
87+
empty.push({ line: i + 1, name })
88+
}
89+
}
90+
return empty
91+
}
92+
93+
/**
94+
* Compute the post-edit text. For Write, that's just `content`. For Edit,
95+
* splice the on-disk file: replace `old_string` with `new_string` once. If the
96+
* on-disk file isn't readable or `old_string` doesn't match exactly, return
97+
* undefined (caller fails open).
98+
*/
99+
export function computePostEditText(
100+
toolName: string,
101+
filePath: string,
102+
newString: string | undefined,
103+
oldString: string | undefined,
104+
content: string | undefined,
105+
): string | undefined {
106+
if (toolName === 'Write') {
107+
return content
108+
}
109+
if (toolName !== 'Edit') {
110+
return undefined
111+
}
112+
if (!existsSync(filePath)) {
113+
return newString
114+
}
115+
if (oldString === undefined || newString === undefined) {
116+
return undefined
117+
}
118+
let raw: string
119+
try {
120+
raw = readFileSync(filePath, 'utf8')
121+
} catch {
122+
return undefined
123+
}
124+
const idx = raw.indexOf(oldString)
125+
if (idx === -1) {
126+
return undefined
127+
}
128+
return raw.slice(0, idx) + newString + raw.slice(idx + oldString.length)
129+
}
130+
131+
export function emitBlock(
132+
filePath: string,
133+
empty: Array<{ line: number; name: string }>,
134+
): void {
135+
const lines: string[] = []
136+
lines.push(
137+
'[changelog-no-empty-sections-guard] Blocked: empty CHANGELOG section(s).',
138+
)
139+
lines.push(` File: ${filePath}`)
140+
lines.push('')
141+
for (const { line, name } of empty) {
142+
lines.push(` Line ${line}: \`### ${name}\` has no bullets.`)
143+
}
144+
lines.push('')
145+
lines.push(
146+
" Per docs/claude.md/fleet/version-bumps.md §2, the CHANGELOG",
147+
)
148+
lines.push(' is public/customer-facing only. When the filter leaves a')
149+
lines.push(' Keep-a-Changelog section empty, delete the heading too — a')
150+
lines.push(' reader scanning the release should not have to disambiguate')
151+
lines.push(' "section intentionally empty" from "section forgot its content."')
152+
lines.push('')
153+
lines.push(` Bypass: type \`${BYPASS_PHRASE}\` in a recent message.`)
154+
process.stderr.write(lines.join('\n') + '\n')
155+
}
156+
157+
type ToolInput = {
158+
tool_input?:
159+
| {
160+
content?: string | undefined
161+
file_path?: string | undefined
162+
new_string?: string | undefined
163+
old_string?: string | undefined
164+
}
165+
| undefined
166+
tool_name?: string | undefined
167+
transcript_path?: string | undefined
168+
}
169+
170+
export function isChangelog(filePath: string | undefined): boolean {
171+
if (!filePath) {
172+
return false
173+
}
174+
const base = path.basename(filePath)
175+
return base === 'CHANGELOG.md'
176+
}
177+
178+
async function main(): Promise<void> {
179+
const raw = await readStdin()
180+
if (!raw) {
181+
return
182+
}
183+
let payload: ToolInput
184+
try {
185+
payload = JSON.parse(raw) as ToolInput
186+
} catch {
187+
return
188+
}
189+
if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') {
190+
return
191+
}
192+
const filePath = payload.tool_input?.file_path ?? ''
193+
if (!isChangelog(filePath)) {
194+
return
195+
}
196+
const postEdit = computePostEditText(
197+
payload.tool_name,
198+
filePath,
199+
payload.tool_input?.new_string,
200+
payload.tool_input?.old_string,
201+
payload.tool_input?.content,
202+
)
203+
if (postEdit === undefined) {
204+
return
205+
}
206+
const empty = findEmptySections(postEdit)
207+
if (empty.length === 0) {
208+
return
209+
}
210+
if (
211+
bypassPhrasePresent(
212+
payload.transcript_path,
213+
BYPASS_PHRASE,
214+
BYPASS_LOOKBACK_USER_TURNS,
215+
)
216+
) {
217+
return
218+
}
219+
emitBlock(filePath, empty)
220+
process.exitCode = 2
221+
}
222+
223+
main().catch(e => {
224+
process.stderr.write(
225+
`[changelog-no-empty-sections-guard] hook error (continuing): ${(e as Error).message}\n`,
226+
)
227+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "hook-changelog-no-empty-sections-guard",
3+
"private": true,
4+
"type": "module",
5+
"main": "./index.mts",
6+
"exports": {
7+
".": "./index.mts"
8+
},
9+
"scripts": {
10+
"test": "node --test test/*.test.mts"
11+
},
12+
"devDependencies": {
13+
"@types/node": "catalog:"
14+
}
15+
}

0 commit comments

Comments
 (0)