Skip to content

Commit 6b65f8c

Browse files
committed
chore(sync): cascade lint-rule + AI-fix updates from socket-repo-template
Picks up the 25 commits from today's template push: 9 new oxlint rules, no-todo-comments → no-placeholders rename, AI-fix Step 4 (scripts/ai-lint-fix.mts), companion script + hook updates. Local prefer-undefined-over-null skip widening (a69d361) preserved. Lint cleanup deferred to a follow-up commit.
1 parent a69d361 commit 6b65f8c

40 files changed

Lines changed: 3952 additions & 163 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# markdown-filename-guard
2+
3+
PreToolUse Edit/Write hook that blocks markdown files with non-canonical filenames.
4+
5+
## What it enforces
6+
7+
| Filename shape | Allowed at | Notes |
8+
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------- | ------------------------------------------------------------- |
9+
| `README.md`, `LICENSE` | anywhere | Special-cased by GitHub. |
10+
| `AUTHORS.md`, `CHANGELOG.md`, `CITATION.md`, `CLAUDE.md`, `CODE_OF_CONDUCT.md`, `CONTRIBUTING.md`, `CONTRIBUTORS.md`, `COPYING`, `CREDITS.md`, `GOVERNANCE.md`, `MAINTAINERS.md`, `NOTICE.md`, `SECURITY.md`, `SUPPORT.md`, `TRADEMARK.md` | repo root, `docs/` (top level), or `.claude/` (top level) | The SCREAMING_CASE allowlist. GitHub renders these specially. |
11+
| `lowercase-with-hyphens.md` | inside `docs/` or `.claude/` (any depth) | All other docs. |
12+
13+
Blocked:
14+
15+
- Custom SCREAMING_CASE filenames (`NOTES.md`, `MY_DESIGN.md`, etc.) — rename to `notes.md` / `my-design.md`.
16+
- `.MD` extension — use `.md`.
17+
- `camelCase.md` / `snake_case.md` / `Spaces In Filename.md` — convert to lowercase-with-hyphens.
18+
- Lowercase-hyphenated docs at repo root — move to `docs/` or `.claude/`.
19+
20+
## Why
21+
22+
SCREAMING_CASE doc filenames optimize for "noticeable in a repo root" but read as shouty + opaque inside body text and TOC links. Lowercase-with-hyphens reads naturally and matches the rest of the fleet's slug-style identifiers (URLs, CSS classes, CLI flags, package names). The narrow SCREAMING_CASE allowlist is the set GitHub renders specially — adding more dilutes the signal.
23+
24+
The fleet's `scripts/validate/markdown-filenames.mts` does the same check at commit time (per repo, not template-canonical); this hook catches it earlier, at edit time, so the model gets immediate feedback when it picks a wrong name.
25+
26+
## Companion files
27+
28+
- `index.mts` — the hook itself.
29+
- `test/index.test.mts` — node:test specs (15 cases).
30+
- `package.json` — workspace declaration so `taze` can see the hook's deps.
31+
- `tsconfig.json` — fleet-canonical TS config.
32+
33+
## Adding a new allowed filename
34+
35+
If GitHub adds a new specially-rendered file (e.g. `FUNDING.md`), update `ALLOWED_SCREAMING_CASE` in `index.mts` and the table above. Don't add custom project-specific SCREAMING_CASE filenames here — those break the convention.
36+
37+
## Failing open
38+
39+
The hook fails open on its own bugs (exit 0 + stderr log) so a bad deploy can't brick the session. The `scripts/validate/markdown-filenames.mts` gate at commit time is the second line of defense.
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
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+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "hook-markdown-filename-guard",
3+
"private": true,
4+
"type": "module",
5+
"main": "./index.mts",
6+
"exports": {
7+
".": "./index.mts"
8+
},
9+
"devDependencies": {
10+
"@types/node": "catalog:"
11+
},
12+
"scripts": {
13+
"test": "node --test test/*.test.mts"
14+
}
15+
}

0 commit comments

Comments
 (0)