Skip to content

Commit 8de2fa7

Browse files
committed
chore(sync): cascade fleet template@727dd6b
Auto-applied by socket-wheelhouse sync-scaffolding into cascade-socket-registry-46397. 19 file(s) touched: - .claude/hooks/no-underscore-identifier-guard/README.md - .claude/hooks/no-underscore-identifier-guard/index.mts - .claude/hooks/no-underscore-identifier-guard/package.json - .claude/hooks/no-underscore-identifier-guard/test/index.test.mts - .claude/hooks/no-underscore-identifier-guard/tsconfig.json - .claude/hooks/setup-security-tools/install.mts - .claude/hooks/setup-security-tools/lib/installers.mts - .claude/hooks/soak-exclude-date-annotation-guard/README.md - .claude/hooks/soak-exclude-date-annotation-guard/index.mts - .claude/hooks/soak-exclude-date-annotation-guard/package.json - .claude/hooks/soak-exclude-date-annotation-guard/test/index.test.mts - .claude/hooks/soak-exclude-date-annotation-guard/tsconfig.json - .claude/settings.json - .config/oxlint-plugin/index.mts - .config/oxlint-plugin/rules/sort-boolean-chains.mts - .config/oxlint-plugin/test/sort-boolean-chains.test.mts - docs/claude.md/fleet/sorting.md - docs/claude.md/fleet/tooling.md - scripts/check-soak-exclude-dates.mts
1 parent 1ec5d1a commit 8de2fa7

19 files changed

Lines changed: 1535 additions & 34 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# no-underscore-identifier-guard
2+
3+
PreToolUse hook that blocks `Edit` / `Write` operations introducing a new
4+
underscore-prefixed **identifier** (`_resetX`, `_internal`, `_cache`, etc.).
5+
6+
## Why
7+
8+
Privacy in TypeScript is handled by module boundaries (not exporting) or by
9+
the `_internal/` _directory_ pattern — not by leading underscores on symbol
10+
names. The underscore-as-internal-marker convention is borrowed from other
11+
languages where it has runtime meaning; in TS it's purely decorative and
12+
adds noise to `git blame` and IDE autocomplete.
13+
14+
## What's banned
15+
16+
| Form | Example |
17+
| ---------- | -------------------------- |
18+
| Variable | `const _cache = new Map()` |
19+
| Function | `function _doResolve() {}` |
20+
| Class | `class _Helper {}` |
21+
| Interface | `interface _Options {}` |
22+
| Type alias | `type _Internal = ...` |
23+
| Re-export | `export { _foo }` |
24+
25+
## What's allowed
26+
27+
- **`_internal/` directories** — the canonical way to signal module-private
28+
files. The rule is about identifiers inside files, not folder layout.
29+
- **Bare `_` throwaway**`for (const _ of arr)`, destructuring rest, etc.
30+
- **Generated output** under `dist/` / `build/` / `node_modules/`.
31+
- **Bypass:** type `Allow underscore-identifier bypass` verbatim in a recent
32+
user turn.
33+
34+
## See also
35+
36+
- CLAUDE.md → "No underscore-prefixed identifiers"
37+
- `.config/oxlint-plugin/rules/no-underscore-identifier.mts` (commit-time
38+
partner of this edit-time hook)
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "hook-no-underscore-identifier-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)