|
| 1 | +#!/usr/bin/env node |
| 2 | +// Claude Code PreToolUse hook — no-underscore-identifier-guard. |
| 3 | +// |
| 4 | +// Blocks Edit/Write tool calls that introduce a new underscore-prefixed |
| 5 | +// *identifier* (function, variable, type, export). Privacy in TypeScript |
| 6 | +// is handled by module boundaries (not exporting) or by `_internal/` |
| 7 | +// *directory* layout — not by leading underscores on symbol names. The |
| 8 | +// underscore-as-internal-marker convention from other languages adds |
| 9 | +// noise without enforcement: TS doesn't treat `_foo` as private, so |
| 10 | +// the underscore is decorative. |
| 11 | +// |
| 12 | +// Banned identifier shapes (recognized at edit time): |
| 13 | +// const _foo = ... |
| 14 | +// let _foo = ... |
| 15 | +// var _foo = ... |
| 16 | +// function _foo(...) |
| 17 | +// class _Foo {...} |
| 18 | +// interface _Foo {...} |
| 19 | +// type _Foo = ... |
| 20 | +// export function _foo(...) |
| 21 | +// export const _foo = ... |
| 22 | +// export { _foo } |
| 23 | +// |
| 24 | +// Allowed (passes through): |
| 25 | +// - `_internal/` directory paths — the canonical way to signal |
| 26 | +// module-private files. The rule is about identifiers inside |
| 27 | +// files, not folder layout. |
| 28 | +// - `_` as a single-character throwaway (`for (const _ of arr)`, |
| 29 | +// destructuring `({ a: _, ...rest })`) — universally understood |
| 30 | +// "I don't care about this value." |
| 31 | +// - `_$$_` / `_$` style names from generated code (rollup, swc |
| 32 | +// temporaries) inside files under `dist/` or `build/`. |
| 33 | +// - Bypass phrase `Allow underscore-identifier bypass` typed |
| 34 | +// verbatim in a recent user turn. |
| 35 | +// |
| 36 | +// Reads PreToolUse JSON payload from stdin: |
| 37 | +// { "tool_name": "Edit"|"Write", |
| 38 | +// "tool_input": { "file_path": "...", "content"|"new_string": "..." } } |
| 39 | +// |
| 40 | +// Exit codes: |
| 41 | +// 0 — pass. |
| 42 | +// 2 — block (at least one banned identifier found). |
| 43 | +// |
| 44 | +// Fails open on malformed payloads (exit 0 + stderr log). |
| 45 | + |
| 46 | +import process from 'node:process' |
| 47 | + |
| 48 | +interface ToolInput { |
| 49 | + readonly tool_input?: |
| 50 | + | { |
| 51 | + readonly content?: string | undefined |
| 52 | + readonly file_path?: string | undefined |
| 53 | + readonly new_string?: string | undefined |
| 54 | + readonly old_string?: string | undefined |
| 55 | + } |
| 56 | + | undefined |
| 57 | + readonly tool_name?: string | undefined |
| 58 | +} |
| 59 | + |
| 60 | +// Match declarations that introduce a leading-underscore identifier. |
| 61 | +// We don't try to AST-parse; the regex set covers the surface forms |
| 62 | +// that show up in TS/JS files in practice. False positives are tolerable |
| 63 | +// here (we'd rather catch + show the line than miss it), and the |
| 64 | +// allowlist covers the canonical exceptions. |
| 65 | +// |
| 66 | +// Each regex captures the offending identifier in group 1 for the |
| 67 | +// error message. We intentionally require at least one alpha char |
| 68 | +// AFTER the underscore — bare `_` is allowed (throwaway). |
| 69 | +const BANNED_DECL_PATTERNS: ReadonlyArray<RegExp> = [ |
| 70 | + // const/let/var _foo |
| 71 | + /\b(?:const|let|var)\s+(_[A-Za-z][A-Za-z0-9_]*)\b/g, |
| 72 | + // function _foo / async function _foo |
| 73 | + /\b(?:async\s+)?function\s*\*?\s+(_[A-Za-z][A-Za-z0-9_]*)\s*\(/g, |
| 74 | + // class _Foo |
| 75 | + /\bclass\s+(_[A-Za-z][A-Za-z0-9_]*)\b/g, |
| 76 | + // interface _Foo |
| 77 | + /\binterface\s+(_[A-Za-z][A-Za-z0-9_]*)\b/g, |
| 78 | + // type _Foo = |
| 79 | + /\btype\s+(_[A-Za-z][A-Za-z0-9_]*)\s*[=<]/g, |
| 80 | + // export { _foo, ... } |
| 81 | + /\bexport\s*\{[^}]*?\b(_[A-Za-z][A-Za-z0-9_]*)\b/g, |
| 82 | +] |
| 83 | + |
| 84 | +const BYPASS_PHRASE = 'Allow underscore-identifier bypass' |
| 85 | + |
| 86 | +interface Finding { |
| 87 | + readonly line: number |
| 88 | + readonly identifier: string |
| 89 | + readonly text: string |
| 90 | +} |
| 91 | + |
| 92 | +function isInternalDirPath(filePath: string): boolean { |
| 93 | + return filePath.includes('/_internal/') |
| 94 | +} |
| 95 | + |
| 96 | +function isGeneratedPath(filePath: string): boolean { |
| 97 | + return ( |
| 98 | + filePath.includes('/dist/') || |
| 99 | + filePath.includes('/build/') || |
| 100 | + filePath.includes('/node_modules/') |
| 101 | + ) |
| 102 | +} |
| 103 | + |
| 104 | +// Hook/lint test files and oxlint-plugin rule files legitimately contain |
| 105 | +// banned identifier *strings* as fixture data. Exempt them so the rule |
| 106 | +// can have its own tests without bypass phrases. |
| 107 | +function isPluginOrHookTestPath(filePath: string): boolean { |
| 108 | + return ( |
| 109 | + filePath.includes('/.claude/hooks/no-underscore-identifier-guard/') || |
| 110 | + filePath.includes( |
| 111 | + '/.config/oxlint-plugin/rules/no-underscore-identifier.', |
| 112 | + ) || |
| 113 | + filePath.includes('/.config/oxlint-plugin/test/no-underscore-identifier') |
| 114 | + ) |
| 115 | +} |
| 116 | + |
| 117 | +function findBannedIdentifiers(text: string): Finding[] { |
| 118 | + const findings: Finding[] = [] |
| 119 | + const lines = text.split('\n') |
| 120 | + for (let i = 0; i < lines.length; i += 1) { |
| 121 | + const line = lines[i]! |
| 122 | + for (const pattern of BANNED_DECL_PATTERNS) { |
| 123 | + pattern.lastIndex = 0 |
| 124 | + let match: RegExpExecArray | null |
| 125 | + while ((match = pattern.exec(line)) !== null) { |
| 126 | + findings.push({ |
| 127 | + line: i + 1, |
| 128 | + identifier: match[1]!, |
| 129 | + text: line.trimEnd(), |
| 130 | + }) |
| 131 | + } |
| 132 | + } |
| 133 | + } |
| 134 | + return findings |
| 135 | +} |
| 136 | + |
| 137 | +function hasRecentBypass(): boolean { |
| 138 | + // Bypass detection is delegated to the harness's transcript reader — |
| 139 | + // we can't see the user turn from here without parsing the env. |
| 140 | + // The harness sets CLAUDE_RECENT_USER_TURNS when a bypass phrase |
| 141 | + // hook is registered upstream; absent that, we look for it in env. |
| 142 | + const turns = process.env['CLAUDE_RECENT_USER_TURNS'] |
| 143 | + if (!turns) { |
| 144 | + return false |
| 145 | + } |
| 146 | + return turns.includes(BYPASS_PHRASE) |
| 147 | +} |
| 148 | + |
| 149 | +async function readStdin(): Promise<string> { |
| 150 | + const chunks: Buffer[] = [] |
| 151 | + for await (const chunk of process.stdin) { |
| 152 | + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) |
| 153 | + } |
| 154 | + return Buffer.concat(chunks).toString('utf8') |
| 155 | +} |
| 156 | + |
| 157 | +async function main(): Promise<void> { |
| 158 | + let payload: ToolInput |
| 159 | + try { |
| 160 | + const raw = await readStdin() |
| 161 | + payload = JSON.parse(raw) as ToolInput |
| 162 | + } catch (err) { |
| 163 | + // Malformed payload — fail open. |
| 164 | + process.stderr.write( |
| 165 | + `no-underscore-identifier-guard: payload parse failed (${(err as Error).message})\n`, |
| 166 | + ) |
| 167 | + process.exit(0) |
| 168 | + } |
| 169 | + |
| 170 | + const toolName = payload.tool_name |
| 171 | + if (toolName !== 'Edit' && toolName !== 'Write') { |
| 172 | + process.exit(0) |
| 173 | + } |
| 174 | + |
| 175 | + const filePath = payload.tool_input?.file_path ?? '' |
| 176 | + if (!filePath) { |
| 177 | + process.exit(0) |
| 178 | + } |
| 179 | + |
| 180 | + // Allowlist: _internal/ dirs, generated output, this rule's own |
| 181 | + // test/lint fixtures. |
| 182 | + if ( |
| 183 | + isInternalDirPath(filePath) || |
| 184 | + isGeneratedPath(filePath) || |
| 185 | + isPluginOrHookTestPath(filePath) |
| 186 | + ) { |
| 187 | + process.exit(0) |
| 188 | + } |
| 189 | + |
| 190 | + // Only police TS/JS source. |
| 191 | + if (!/\.(?:m|c)?[jt]sx?$/.test(filePath)) { |
| 192 | + process.exit(0) |
| 193 | + } |
| 194 | + |
| 195 | + const text = |
| 196 | + payload.tool_input?.content ?? payload.tool_input?.new_string ?? '' |
| 197 | + if (!text) { |
| 198 | + process.exit(0) |
| 199 | + } |
| 200 | + |
| 201 | + const findings = findBannedIdentifiers(text) |
| 202 | + if (findings.length === 0) { |
| 203 | + process.exit(0) |
| 204 | + } |
| 205 | + |
| 206 | + if (hasRecentBypass()) { |
| 207 | + process.stderr.write( |
| 208 | + `no-underscore-identifier-guard: ${findings.length} underscore identifier(s) — bypassed via "${BYPASS_PHRASE}"\n`, |
| 209 | + ) |
| 210 | + process.exit(0) |
| 211 | + } |
| 212 | + |
| 213 | + const lines = findings |
| 214 | + .map(f => ` ${filePath}:${f.line} ${f.identifier}\n ${f.text}`) |
| 215 | + .join('\n') |
| 216 | + process.stderr.write( |
| 217 | + `no-underscore-identifier-guard: refusing to introduce underscore-prefixed identifier(s).\n` + |
| 218 | + `\n` + |
| 219 | + `${lines}\n` + |
| 220 | + `\n` + |
| 221 | + `Drop the leading underscore. Privacy in TypeScript is handled by:\n` + |
| 222 | + ` - not exporting the symbol (module boundary), or\n` + |
| 223 | + ` - placing the file under a "_internal/" directory.\n` + |
| 224 | + `\n` + |
| 225 | + `Bypass: type "${BYPASS_PHRASE}" in a recent message.\n`, |
| 226 | + ) |
| 227 | + process.exit(2) |
| 228 | +} |
| 229 | + |
| 230 | +main().catch((err: unknown) => { |
| 231 | + process.stderr.write( |
| 232 | + `no-underscore-identifier-guard: unexpected error (${(err as Error).message})\n`, |
| 233 | + ) |
| 234 | + process.exit(0) |
| 235 | +}) |
0 commit comments