|
| 1 | +#!/usr/bin/env node |
| 2 | +// Claude Code PreToolUse hook — markdown-filename-guard. |
| 3 | +// |
| 4 | +// Blocks Edit/Write tool calls that would create a markdown file |
| 5 | +// with a non-canonical filename. Per the fleet's docs convention: |
| 6 | +// |
| 7 | +// - Allowed everywhere: README.md, LICENSE. |
| 8 | +// - Allowed at root, docs/, or .claude/ (top level only): the |
| 9 | +// conventional SCREAMING_CASE set (AUTHORS, CHANGELOG, CLAUDE, |
| 10 | +// CODE_OF_CONDUCT, CONTRIBUTING, GOVERNANCE, MAINTAINERS, |
| 11 | +// NOTICE, SECURITY, SUPPORT, etc.). |
| 12 | +// - Everything else must be lowercase-with-hyphens AND placed |
| 13 | +// under `docs/` or `.claude/` (at any depth). |
| 14 | +// |
| 15 | +// Why: SCREAMING_CASE doc filenames optimize for "noticeable in a |
| 16 | +// repo root" but read as shouty + opaque inside body text and TOC |
| 17 | +// links. Hyphenated lowercase reads naturally and matches every |
| 18 | +// other slug-style identifier the fleet uses (URLs, CSS classes, |
| 19 | +// CLI flags, package names). The narrow SCREAMING_CASE allowlist is |
| 20 | +// the set GitHub renders specially — adding more would dilute the |
| 21 | +// signal. |
| 22 | +// |
| 23 | +// The fleet's `scripts/validate/markdown-filenames.mts` does the |
| 24 | +// same check at commit time; this hook catches it earlier, at edit |
| 25 | +// time, so the model gets immediate feedback when it picks a wrong |
| 26 | +// name. |
| 27 | +// |
| 28 | +// Exit code 2 makes Claude Code refuse the tool call. |
| 29 | +// |
| 30 | +// Reads a Claude Code PreToolUse JSON payload from stdin: |
| 31 | +// { "tool_name": "Edit"|"Write", |
| 32 | +// "tool_input": { "file_path": "...", "content"|"new_string": "..." } } |
| 33 | +// |
| 34 | +// Fails open on hook bugs (exit 0 + stderr log). |
| 35 | + |
| 36 | +import path from 'node:path' |
| 37 | +import process from 'node:process' |
| 38 | + |
| 39 | +type ToolInput = { |
| 40 | + tool_input?: |
| 41 | + | { |
| 42 | + content?: string | undefined |
| 43 | + file_path?: string | undefined |
| 44 | + new_string?: string | undefined |
| 45 | + } |
| 46 | + | undefined |
| 47 | + tool_name?: string | undefined |
| 48 | +} |
| 49 | + |
| 50 | +// SCREAMING_CASE files allowed at root / docs/ / .claude/ (top level). |
| 51 | +const ALLOWED_SCREAMING_CASE: ReadonlySet<string> = new Set([ |
| 52 | + 'AUTHORS', |
| 53 | + 'CHANGELOG', |
| 54 | + 'CITATION', |
| 55 | + 'CLAUDE', |
| 56 | + 'CODE_OF_CONDUCT', |
| 57 | + 'CONTRIBUTING', |
| 58 | + 'CONTRIBUTORS', |
| 59 | + 'COPYING', |
| 60 | + 'CREDITS', |
| 61 | + 'GOVERNANCE', |
| 62 | + 'LICENSE', |
| 63 | + 'MAINTAINERS', |
| 64 | + 'NOTICE', |
| 65 | + 'README', |
| 66 | + 'SECURITY', |
| 67 | + 'SUPPORT', |
| 68 | + 'TRADEMARK', |
| 69 | +]) |
| 70 | + |
| 71 | +function readStdin(): Promise<string> { |
| 72 | + return new Promise(resolve => { |
| 73 | + let buf = '' |
| 74 | + process.stdin.setEncoding('utf8') |
| 75 | + process.stdin.on('data', chunk => { |
| 76 | + buf += chunk |
| 77 | + }) |
| 78 | + process.stdin.on('end', () => resolve(buf)) |
| 79 | + }) |
| 80 | +} |
| 81 | + |
| 82 | +/** |
| 83 | + * Strip a leading repo-absolute prefix (anything up through and |
| 84 | + * including a `<repo-name>/` segment) so we get the in-repo relative |
| 85 | + * path. Falls back to the input if no recognizable prefix. |
| 86 | + */ |
| 87 | +function toRepoRelative(filePath: string): string { |
| 88 | + // PreToolUse passes absolute paths. Strip up through `/projects/<repo>/`. |
| 89 | + const m = filePath.match(/\/projects\/[^/]+\/(.+)$/) |
| 90 | + return m ? m[1]! : filePath |
| 91 | +} |
| 92 | + |
| 93 | +function isScreamingCase(nameWithoutExt: string): boolean { |
| 94 | + return /^[A-Z0-9_]+$/.test(nameWithoutExt) && /[A-Z]/.test(nameWithoutExt) |
| 95 | +} |
| 96 | + |
| 97 | +function isLowercaseHyphenated(nameWithoutExt: string): boolean { |
| 98 | + return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(nameWithoutExt) |
| 99 | +} |
| 100 | + |
| 101 | +function isAtAllowedScreamingLocation(relPath: string): boolean { |
| 102 | + const dir = path.posix.dirname(relPath) |
| 103 | + return dir === '.' || dir === 'docs' || dir === '.claude' |
| 104 | +} |
| 105 | + |
| 106 | +function isAtAllowedRegularLocation(relPath: string): boolean { |
| 107 | + const dir = path.posix.dirname(relPath) |
| 108 | + return ( |
| 109 | + dir === 'docs' || |
| 110 | + dir.startsWith('docs/') || |
| 111 | + dir === '.claude' || |
| 112 | + dir.startsWith('.claude/') |
| 113 | + ) |
| 114 | +} |
| 115 | + |
| 116 | +type Verdict = { |
| 117 | + ok: boolean |
| 118 | + message?: string |
| 119 | + suggestion?: string |
| 120 | +} |
| 121 | + |
| 122 | +export function classifyMarkdownPath(absPath: string): Verdict { |
| 123 | + const filename = path.basename(absPath) |
| 124 | + if (!/\.(md|MD|markdown)$/.test(filename)) { |
| 125 | + return { ok: true } |
| 126 | + } |
| 127 | + |
| 128 | + const relPath = toRepoRelative(absPath).split(path.sep).join('/') |
| 129 | + const nameWithoutExt = filename.replace(/\.(md|MD|markdown)$/, '') |
| 130 | + |
| 131 | + // README / LICENSE — anywhere. |
| 132 | + if (nameWithoutExt === 'README' || nameWithoutExt === 'LICENSE') { |
| 133 | + return { ok: true } |
| 134 | + } |
| 135 | + |
| 136 | + // SCREAMING_CASE allowlist. |
| 137 | + if (ALLOWED_SCREAMING_CASE.has(nameWithoutExt)) { |
| 138 | + if (isAtAllowedScreamingLocation(relPath)) { |
| 139 | + return { ok: true } |
| 140 | + } |
| 141 | + const lowered = filename.toLowerCase().replace(/_/g, '-') |
| 142 | + return { |
| 143 | + ok: false, |
| 144 | + message: `${filename} (SCREAMING_CASE) is allowed only at the repo root, docs/, or .claude/. This path puts it deeper.`, |
| 145 | + suggestion: `Either move to root / docs/ / .claude/, or rename to ${lowered}.`, |
| 146 | + } |
| 147 | + } |
| 148 | + |
| 149 | + // Wrong-case extension `.MD`. |
| 150 | + if (filename.endsWith('.MD')) { |
| 151 | + return { |
| 152 | + ok: false, |
| 153 | + message: `Extension is .MD; the fleet uses .md.`, |
| 154 | + suggestion: filename.replace(/\.MD$/, '.md'), |
| 155 | + } |
| 156 | + } |
| 157 | + |
| 158 | + // SCREAMING_CASE not in the allowlist — never allowed. |
| 159 | + if (isScreamingCase(nameWithoutExt)) { |
| 160 | + return { |
| 161 | + ok: false, |
| 162 | + message: `${filename}: SCREAMING_CASE markdown filenames are limited to the canonical allowlist (AUTHORS, CHANGELOG, CLAUDE, README, SECURITY, etc.). Custom doc names should be lowercase-with-hyphens.`, |
| 163 | + suggestion: filename.toLowerCase().replace(/_/g, '-'), |
| 164 | + } |
| 165 | + } |
| 166 | + |
| 167 | + // Must be lowercase-with-hyphens. |
| 168 | + if (!isLowercaseHyphenated(nameWithoutExt)) { |
| 169 | + const suggested = nameWithoutExt |
| 170 | + .toLowerCase() |
| 171 | + .replace(/[_\s]+/g, '-') |
| 172 | + .replace(/[^a-z0-9-]/g, '') |
| 173 | + return { |
| 174 | + ok: false, |
| 175 | + message: `${filename}: doc filenames must be lowercase-with-hyphens (no underscores, no camelCase, no spaces).`, |
| 176 | + suggestion: `${suggested}.md`, |
| 177 | + } |
| 178 | + } |
| 179 | + |
| 180 | + // Lowercase-hyphenated docs must live under docs/ or .claude/. |
| 181 | + if (!isAtAllowedRegularLocation(relPath)) { |
| 182 | + return { |
| 183 | + ok: false, |
| 184 | + message: `${filename}: per-repo docs live under docs/ or .claude/, not at ${path.posix.dirname(relPath) || '.'}.`, |
| 185 | + suggestion: `Move to docs/${filename} or .claude/${filename}.`, |
| 186 | + } |
| 187 | + } |
| 188 | + |
| 189 | + return { ok: true } |
| 190 | +} |
| 191 | + |
| 192 | +function emitBlock(filePath: string, verdict: Verdict): void { |
| 193 | + const lines: string[] = [] |
| 194 | + lines.push('[markdown-filename-guard] Blocked: non-canonical doc filename.') |
| 195 | + lines.push(` File: ${filePath}`) |
| 196 | + if (verdict.message) { |
| 197 | + lines.push(` Issue: ${verdict.message}`) |
| 198 | + } |
| 199 | + if (verdict.suggestion) { |
| 200 | + lines.push(` Suggestion: ${verdict.suggestion}`) |
| 201 | + } |
| 202 | + lines.push('') |
| 203 | + lines.push(' Fleet doc-filename rules:') |
| 204 | + lines.push(' - README.md / LICENSE — allowed anywhere.') |
| 205 | + lines.push( |
| 206 | + ' - SCREAMING_CASE allowlist (AUTHORS, CHANGELOG, CLAUDE, CONTRIBUTING,', |
| 207 | + ) |
| 208 | + lines.push( |
| 209 | + ' GOVERNANCE, MAINTAINERS, NOTICE, README, SECURITY, SUPPORT, …) —', |
| 210 | + ) |
| 211 | + lines.push(' allowed at root / docs/ / .claude/ (top level only).') |
| 212 | + lines.push( |
| 213 | + ' - Everything else: lowercase-with-hyphens, in docs/ or .claude/.', |
| 214 | + ) |
| 215 | + process.stderr.write(lines.join('\n') + '\n') |
| 216 | +} |
| 217 | + |
| 218 | +async function main(): Promise<void> { |
| 219 | + const raw = await readStdin() |
| 220 | + if (!raw) { |
| 221 | + return |
| 222 | + } |
| 223 | + let payload: ToolInput |
| 224 | + try { |
| 225 | + payload = JSON.parse(raw) as ToolInput |
| 226 | + } catch { |
| 227 | + return |
| 228 | + } |
| 229 | + if (payload.tool_name !== 'Edit' && payload.tool_name !== 'Write') { |
| 230 | + return |
| 231 | + } |
| 232 | + const filePath = payload.tool_input?.file_path ?? '' |
| 233 | + if (!filePath) { |
| 234 | + return |
| 235 | + } |
| 236 | + const verdict = classifyMarkdownPath(filePath) |
| 237 | + if (verdict.ok) { |
| 238 | + return |
| 239 | + } |
| 240 | + emitBlock(filePath, verdict) |
| 241 | + process.exitCode = 2 |
| 242 | +} |
| 243 | + |
| 244 | +main().catch(e => { |
| 245 | + process.stderr.write( |
| 246 | + `[markdown-filename-guard] hook error (continuing): ${(e as Error).message}\n`, |
| 247 | + ) |
| 248 | +}) |
0 commit comments