Enforce the style rules Claude keeps ignoring: banned words and phrases, a comment-density cap on added code, and files that must not gain comments. Rules live…

Enforce the style rules Claude keeps ignoring. Two of the most-reacted model-behaviour issues on the Claude Code tracker are verbose code comments that survive every instruction to stop, and the word "load-bearing" in every other reply. A rule in CLAUDE.md is a request; this mod makes the measurable ones a refusal.
Rules are shown to Claude on every prompt, and the measurable ones are enforced on Edit, Write, and MultiEdit: an edit that adds a banned phrase, too many comment lines, or a comment to a file that must stay comment-free is refused with the exact line or count, and Claude rewrites it.
/plugin marketplace add MDmubarak786/claude-mods
/plugin install style-cop@modhub
Try it for one session without installing:
claude --plugin-dir ./mods/style-cop
Create .claude/style-cop.md in your project, one rule per line:
banned: load-bearing, delve, "as an AI"
max-comment-ratio: 0.25
no-comments-in: *.json, migrations/**
rule: Prefer early returns over nested conditionals.
| Rule | Enforced on edits | Shown to Claude |
|---|---|---|
banned: words or phrases, comma-separated, quotes optional | Yes: any added line containing one, whole-word, case-insensitive | Yes |
max-comment-ratio: a fraction | Yes: when an edit adds 5 or more non-blank lines and more than that share are comments, by the file's language | Yes |
no-comments-in: globs | Yes: any comment line added to a matching file | Yes |
rule: free text | No | Yes |
What Claude reads when an edit is refused:
style-cop: this edit was refused because of 4 comment lines out of 7 added (57%, the limit is 25%). Rewrite it without the violation and try again. The project style rules are in your context.
| Command | What it does |
|---|---|
/style-cop | Show the active rules, where they came from, and the refusal count. |
/style-cop reload | Re-read the rules file after editing it. |
/style-cop ban <phrase> | Ban a phrase in every project. Saved across sessions. |
/style-cop unban <phrase> | Remove a global ban. |
From claude plugin validate ./mods/style-cop:
hooks: session.start, command.run{command=style-cop}, prompt.submit, tool.call{tool=Edit|Write|MultiEdit}
calls: $.command.register, $.fs.exists (via loadRules), $.fs.read (via loadRules), $.session.root (via loadRules), $.store.get (via loadRules), $.store.set, $.ui.log
prompt.submit adds the rules as context after your prompt. Your message in the transcript is unchanged. The text is stable between prompts, so it doesn't disturb the prompt cache.$.fs.read reads only .claude/style-cop.md under the project root.$.store holds the global bans.Failure policy. This is a guard, so if the check throws or times out the edit is refused rather than let through.
claude plugin validate --strict and claude plugin test pass. /style-cop answered from a live claude -p session.//, #, --, <!--, or a block-comment marker. Trailing comments on code lines don't count, and languages not in the list aren't measured.rule: is advice to Claude, not a check.MIT, see the repository root.
hooks/register.ts 191 lines1// style-cop: enforce the style rules Claude keeps ignoring.
2//
3// /style-cop show the active rules and how many edits were refused
4// /style-cop reload re-read .claude/style-cop.md
5// /style-cop ban <phrase> ban a word or phrase everywhere, saved across projects
6// /style-cop unban <phrase>
7//
8// Rules live in <project>/.claude/style-cop.md, one per line:
9// banned: load-bearing, delve, "as an AI"
10// max-comment-ratio: 0.25
11// no-comments-in: *.json, migrations/**
12// rule: Prefer early returns over nested conditionals.
13// Every prompt carries the rules as context for Claude. The measurable ones
14// (banned phrases, comment density, no-comment files) are enforced on Edit,
15// Write, and MultiEdit: a violating edit is refused with the exact line.
16
17const MIN_LINES_FOR_RATIO = 5
18
19type Rules = { banned: string[]; maxCommentRatio: number | null; noCommentsIn: string[]; free: string[] }
20
21let root = ''
22let rules: Rules = { banned: [], maxCommentRatio: null, noCommentsIn: [], free: [] }
23let globalBans: string[] = []
24let refusals = 0
25let loadedFrom = ''
26
27function parseRules(text: string): Rules {
28 const r: Rules = { banned: [], maxCommentRatio: null, noCommentsIn: [], free: [] }
29 for (const raw of text.split('\n')) {
30 const line = raw.trim()
31 const m = /^([a-z-]+):\s*(.*)$/i.exec(line)
32 if (!m) continue
33 const [, key, value] = m
34 const list = () => value.split(',').map((s) => s.trim().replace(/^"(.*)"$/, '$1')).filter(Boolean)
35 if (key === 'banned') r.banned.push(...list())
36 else if (key === 'max-comment-ratio') r.maxCommentRatio = Number(value) || null
37 else if (key === 'no-comments-in') r.noCommentsIn.push(...list())
38 else if (key === 'rule') r.free.push(value)
39 }
40 return r
41}
42
43async function loadRules($) {
44 try {
45 root = await $.session.root()
46 } catch {
47 root = ''
48 }
49 try {
50 const saved = await $.store.get('bans')
51 globalBans = Array.isArray(saved) ? saved.filter((b) => typeof b === 'string') : []
52 } catch {
53 globalBans = []
54 }
55 const file = root + '/.claude/style-cop.md'
56 try {
57 if (await $.fs.exists(file)) {
58 rules = parseRules(await $.fs.read(file))
59 loadedFrom = file
60 } else {
61 rules = { banned: [], maxCommentRatio: null, noCommentsIn: [], free: [] }
62 loadedFrom = ''
63 }
64 } catch (error) {
65 $.ui.log('style-cop could not read ' + file + ': ' + error)
66 }
67}
68
69function allBans(): string[] {
70 return [...new Set([...rules.banned, ...globalBans])]
71}
72
73function contextText(): string | null {
74 const parts: string[] = []
75 const bans = allBans()
76 if (bans.length) parts.push('Never use these words or phrases, in code, comments, or prose: ' + bans.join(', ') + '.')
77 if (rules.maxCommentRatio !== null) parts.push('Keep comments to at most ' + Math.round(rules.maxCommentRatio * 100) + '% of the lines you add. Do not explain what the code plainly says.')
78 if (rules.noCommentsIn.length) parts.push('Add no comments to files matching: ' + rules.noCommentsIn.join(', ') + '.')
79 parts.push(...rules.free)
80 return parts.length ? 'Style rules for this project (edits that break the measurable ones are refused):\n- ' + parts.join('\n- ') : null
81}
82
83const COMMENT_BY_EXT: Record<string, RegExp> = {
84 js: /^\s*(\/\/|\/\*|\*)/, jsx: /^\s*(\/\/|\/\*|\*)/, ts: /^\s*(\/\/|\/\*|\*)/, tsx: /^\s*(\/\/|\/\*|\*)/, mjs: /^\s*(\/\/|\/\*|\*)/, cjs: /^\s*(\/\/|\/\*|\*)/,
85 java: /^\s*(\/\/|\/\*|\*)/, kt: /^\s*(\/\/|\/\*|\*)/, swift: /^\s*(\/\/|\/\*|\*)/, go: /^\s*(\/\/|\/\*|\*)/, rs: /^\s*(\/\/|\/\*|\*)/, c: /^\s*(\/\/|\/\*|\*)/, h: /^\s*(\/\/|\/\*|\*)/, cpp: /^\s*(\/\/|\/\*|\*)/, cs: /^\s*(\/\/|\/\*|\*)/, scala: /^\s*(\/\/|\/\*|\*)/, dart: /^\s*(\/\/|\/\*|\*)/, php: /^\s*(\/\/|\/\*|\*|#)/,
86 py: /^\s*#/, rb: /^\s*#/, sh: /^\s*#/, bash: /^\s*#/, zsh: /^\s*#/, yaml: /^\s*#/, yml: /^\s*#/, toml: /^\s*#/, r: /^\s*#/, pl: /^\s*#/, ex: /^\s*#/, exs: /^\s*#/,
87 json: /^\s*(\/\/|\/\*|\*)/, jsonc: /^\s*(\/\/|\/\*|\*)/, json5: /^\s*(\/\/|\/\*|\*)/,
88 sql: /^\s*--/, lua: /^\s*--/, hs: /^\s*--/, html: /^\s*<!--/, xml: /^\s*<!--/, vue: /^\s*(<!--|\/\/)/, svelte: /^\s*(<!--|\/\/)/, css: /^\s*(\/\*|\*)/, scss: /^\s*(\/\/|\/\*|\*)/,
89}
90
91function commentPattern(file: string): RegExp | null {
92 const ext = file.split('.').pop()?.toLowerCase() ?? ''
93 return COMMENT_BY_EXT[ext] ?? null
94}
95
96// A minimal glob, gitignore-style: ** matches any path, * matches within a segment,
97// and a glob with no leading / or ** matches at any depth.
98function matchesGlob(file: string, glob: string): boolean {
99 const rel = root && file.startsWith(root + '/') ? file.slice(root.length + 1) : file
100 const anchored = glob.startsWith('/') || glob.startsWith('**')
101 const body = glob
102 .replace(/^\//, '')
103 .replace(/[.+^${}()|[\]\\]/g, '\\$&')
104 .replace(/\*\*\//g, '(?:.*/)?')
105 .replace(/\*\*/g, '.*')
106 .replace(/\*/g, '[^/]*')
107 const re = new RegExp('^' + (anchored ? '' : '(?:.*/)?') + body + '$')
108 return re.test(rel) || re.test(file)
109}
110
111// What the edit adds: new_string for Edit, content for Write, every new_string for MultiEdit.
112function addedText(e): string {
113 if (e.tool === 'Write') return typeof e.content === 'string' ? e.content : ''
114 if (e.tool === 'MultiEdit') return Array.isArray(e.edits) ? e.edits.map((x) => x.new_string ?? '').join('\n') : ''
115 return typeof e.new_string === 'string' ? e.new_string : ''
116}
117
118function violation(file: string, text: string): string | null {
119 const lines = text.split('\n')
120 for (const phrase of allBans()) {
121 const re = new RegExp('(^|[^\\w-])' + phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '($|[^\\w-])', 'i')
122 const at = lines.findIndex((l) => re.test(l))
123 if (at >= 0) return 'the banned phrase "' + phrase + '" on the line: ' + lines[at].trim()
124 }
125 const pattern = commentPattern(file)
126 if (!pattern) return null
127 const nonBlank = lines.filter((l) => l.trim())
128 const comments = nonBlank.filter((l) => pattern.test(l))
129 if (rules.noCommentsIn.some((g) => matchesGlob(file, g)) && comments.length) {
130 return 'a comment in a file that must not gain comments: ' + comments[0].trim()
131 }
132 if (rules.maxCommentRatio !== null && nonBlank.length >= MIN_LINES_FOR_RATIO) {
133 const ratio = comments.length / nonBlank.length
134 if (ratio > rules.maxCommentRatio) return comments.length + ' comment lines out of ' + nonBlank.length + ' added (' + Math.round(ratio * 100) + '%, the limit is ' + Math.round(rules.maxCommentRatio * 100) + '%)'
135 }
136 return null
137}
138
139async function guard($, e, next) {
140 const file = typeof e.file_path === 'string' ? e.file_path : ''
141 const text = addedText(e)
142 if (!file || !text) return next(e)
143 const why = violation(file, text)
144 if (!why) return next(e)
145 refusals += 1
146 $.ui.log('refused an edit to ' + file + ': ' + why)
147 return { deny: 'style-cop: this edit was refused because of ' + why + '. Rewrite it without the violation and try again. The project style rules are in your context.' }
148}
149
150export function register(on) {
151 on('session.start', async ($, e, next) => {
152 await loadRules($)
153 try {
154 await $.command.register({ name: 'style-cop', description: 'Show, reload, or extend the enforced style rules', argumentHint: '[reload | ban <phrase> | unban <phrase>]' })
155 } catch (error) {
156 $.ui.log('could not register /style-cop: ' + error)
157 }
158 return next(e)
159 })
160
161 on('command.run', { command: 'style-cop' }, async ($, e) => {
162 const args = e.args.trim()
163 if (args === 'reload') {
164 await loadRules($)
165 return { text: loadedFrom ? 'Reloaded ' + loadedFrom : 'No .claude/style-cop.md in this project; only global bans apply.' }
166 }
167 const ban = /^(ban|unban)\s+(.+)$/.exec(args)
168 if (ban) {
169 const phrase = ban[2].trim().replace(/^"(.*)"$/, '$1')
170 globalBans = ban[1] === 'ban' ? [...new Set([...globalBans, phrase])] : globalBans.filter((b) => b.toLowerCase() !== phrase.toLowerCase())
171 await $.store.set('bans', globalBans)
172 return { text: (ban[1] === 'ban' ? 'Banned "' : 'Unbanned "') + phrase + '" everywhere.' }
173 }
174 const ctx = contextText()
175 return {
176 text: (loadedFrom ? 'Rules from ' + loadedFrom : 'No .claude/style-cop.md in this project') + '. Global bans: ' + (globalBans.length ? globalBans.join(', ') : 'none') + '. Refused ' + refusals + ' edit(s) since load.' + (ctx ? '\n\n' + ctx : ''),
177 }
178 }).catch(async () => ({ text: 'style-cop: the command failed, so nothing changed.' }))
179
180 on('prompt.submit', async ($, e, next) => {
181 const ctx = contextText()
182 if (!ctx) return next(e)
183 return next({ ...e, context: [...(e.context ?? []), ctx] })
184 }).catch(async ($, e, next) => next(e))
185
186 on('tool.call', { tool: ['Edit', 'Write', 'MultiEdit'] }, guard).catch(async ($, e, next) => {
187 if (next.called) return next(e)
188 return { deny: 'style-cop: could not check this edit (' + next.error.kind + '), so it was not made. Try again.' }
189 })
190}
191