Agent 写 Java 时,新增的阿里 Java 规约(p3c)违规落不了盘。Blocks Java writes that add Alibaba p3c violations.

Agent 写 Java 时,违反《阿里巴巴 Java 开发手册》的代码落不了盘。
一个 Claude Code Mod。它在 Write / Edit 真正写入 *.java 之前跑一遍 p3c 规则,新增了违规就拒绝这次写入,并把规则、行号和改法交还给 Agent,Agent 改对了才能写进去。
Agent 想写这样一个文件:
public class OrderService {
public boolean run(Integer left, Integer right) {
ExecutorService pool = Executors.newFixedThreadPool(4);
pool.shutdown();
return left == right;
}
}
写入被拒绝,Agent 收到:
p3c-guard:这次写入会给 OrderService.java 新增 2 处阿里 Java 规约违规,未写入。
- 第 8 行 [ThreadPoolCreationRule] 手动创建线程池,效果会更好哦。 改法:用 new ThreadPoolExecutor(...) 显式给出核心数、最大数、有界队列和带名字的 ThreadFactory;Spring 项目注入已配置的 ThreadPoolTaskExecutor。
- 第 10 行 [WrapperTypeEqualityRule] 应使用equals方法代替== 改法:用 Objects.equals(a, b) 比较包装类型。
行号是写入后文件里的行号。请修正后重新写入;文件原有的违规不在此列。
它会自己改完再写一次。你不用盯着,也不用等到代码审查才发现。
在 Claude Code 里输入:
/plugin install p3c-guard --marketplace alexlifexyz/p3c-guard
需要本机有 JDK 21 或更高版本(java 在 PATH 里)和 curl。
第一次遇到 Java 写入时,它会在后台下载规则引擎(13 MB,来自 Maven Central,校验 SHA-256)到 ~/.cache/p3c-guard/。下载期间的写入不检查,也不会卡住你。
| 做法 | 什么时候起作用 | |
|---|---|---|
| 手册型 Skill | 把规约写成文档给模型读 | 模型记得的时候 |
| 代码审查工具 | 用模型审 diff | 写完之后,你想起来跑的时候 |
| p3c-guard | 用语法树规则做确定性检查 | 每一次写入之前 |
检查不经过模型,同样的代码每次结果一样,单个文件约 1 秒。
@author、魔法值、未指定集合容量等 25 条)不拦。装上就生效,状态栏显示 p3c 拦 2 · 过 14。
| 命令 | 作用 |
|---|---|
/p3c | 查看状态和本会话的拦截次数 |
/p3c off | 暂停 |
/p3c on | 恢复 |
配置项 blockPriority(在 /config 里改):1 只拦最严重的,2 为默认,3 全拦。
catch 块、日志规范、SQL 规约等没有对应规则的条目查不到。SimpleDateFormat 在 lambda 里调用时查不出。Write 和 Edit。 Agent 用 shell 命令改文件时不经过它。p3c-guard 自己不包含也不分发任何规则实现。它在运行时下载并调用 loong95/p3c-cmd-jdk21(Apache-2.0),这是把 alibaba/p3c 的规则移植到 PMD 7 和 JDK 21 的非官方版本。官方 p3c 的规则引擎停在 PMD 6.15,解析不了 record、switch 表达式、文本块。
本项目与阿里巴巴集团无关,也未获其背书。
claude plugin validate .
claude plugin test .
claude --plugin-dir .
hooks/register.ts 329 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const JAR_VERSION = '3.0.1'
4const JAR_NAME = `p3c-pmd-jdk21-${JAR_VERSION}-cli.jar`
5const JAR_URL = `https://repo1.maven.org/maven2/io/github/loong95/p3c-pmd-jdk21/${JAR_VERSION}/${JAR_NAME}`
6const JAR_SHA256 = '4caf54a3a8f33cc079a16857147168f268cae7e86072413e9ab07365dc311a16'
7const RULESET = 'rulesets/java/ali-all.xml'
8const MAX_LISTED = 10
9
10// How to fix the rules whose own message does not say; shown after it.
11const FIXES: Readonly<Record<string, string>> = {
12 ThreadPoolCreationRule:
13 '用 new ThreadPoolExecutor(...) 显式给出核心数、最大数、有界队列和带名字的 ThreadFactory;Spring 项目注入已配置的 ThreadPoolTaskExecutor。',
14 AvoidManuallyCreateThreadRule: '把任务提交给已有的线程池,不要 new Thread()。',
15 ThreadShouldSetNameRule: '给 ThreadFactory 或线程池设置有业务含义的线程名前缀。',
16 AvoidCallStaticSimpleDateFormatRule:
17 '改用线程安全的 DateTimeFormatter,或每次调用新建 SimpleDateFormat。',
18 ThreadLocalShouldRemoveRule: '在 finally 里调用 remove()。',
19 AvoidUseTimerRule: '改用 ScheduledThreadPoolExecutor。',
20 LockShouldWithTryFinallyRule: 'lock() 紧接 try,unlock() 放在 finally 的第一行。',
21 DontModifyInForeachCircleRule: '改用 Iterator.remove() 或 Collection.removeIf(...)。',
22 WrapperTypeEqualityRule: '用 Objects.equals(a, b) 比较包装类型。',
23 AvoidDoubleOrFloatEqualCompareRule: '用 BigDecimal.compareTo,或比较差值是否小于给定误差。',
24 AvoidApacheBeanUtilsCopyRule: '改用 Spring BeanUtils 或 MapStruct。',
25 AvoidNewDateGetTimeRule: '改用 System.currentTimeMillis()。',
26 AvoidPatternCompileInMethodRule: '把 Pattern.compile(...) 提成 private static final 常量。',
27 EqualsAvoidNullRule: '把常量或确定非空的值放在 equals 左边,或用 Objects.equals。',
28}
29
30// PMD exits 4 when it found violations and 5 when a file did not parse.
31const PMD_OK = [0, 4, 5]
32
33type Engine = EngineInterface
34
35export type Violation = {
36 rule: string
37 line: number
38 priority: number
39 description: string
40}
41
42type PmdReport = {
43 files?: {
44 filename: string
45 violations: {
46 rule: string
47 beginline: number
48 priority: number
49 description: string
50 }[]
51 }[]
52 processingErrors?: { filename: string }[]
53}
54
55/** The file's text once the edit is applied, or undefined when it would not apply. */
56export const applyEdit = (
57 text: string,
58 oldString: string,
59 newString: string,
60 replaceAll: boolean,
61): string | undefined => {
62 if (oldString === '' || !text.includes(oldString)) {
63 return undefined
64 }
65
66 return replaceAll
67 ? text.split(oldString).join(newString)
68 : text.replace(oldString, () => newString)
69}
70
71/** The violations in `after` that `before` did not already have, at or above the blocking level. */
72export const addedViolations = (
73 before: readonly Violation[],
74 after: readonly Violation[],
75 blockPriority: number,
76): Violation[] => {
77 const known = new Map<string, number>()
78
79 for (const one of before) {
80 const key = `${one.rule}\n${one.description}`
81 known.set(key, (known.get(key) ?? 0) + 1)
82 }
83
84 return after.filter(one => {
85 if (one.priority > blockPriority) {
86 return false
87 }
88
89 const key = `${one.rule}\n${one.description}`
90 const left = known.get(key) ?? 0
91 known.set(key, left - 1)
92
93 return left <= 0
94 })
95}
96
97const state = {
98 blockPriority: 2,
99 isEnabled: true,
100 blocked: 0,
101 passed: 0,
102 jar: undefined as Promise<string> | undefined,
103 isJarReady: false,
104}
105
106const fetchJar = async ($: Engine): Promise<string> => {
107 const home = await $.env.get('HOME')
108
109 if (home === undefined) {
110 throw new Error('HOME is not set')
111 }
112
113 const dir = `${home}/.cache/p3c-guard`
114 const path = `${dir}/${JAR_NAME}`
115
116 if (!(await $.fs.exists(path))) {
117 const part = `${path}.part`
118 await $.process.run(['mkdir', '-p', dir])
119 const got = await $.process.run(
120 ['curl', '-fsSL', '-o', part, JAR_URL],
121 { timeoutMs: 300_000 },
122 )
123
124 if (got.exitCode !== 0) {
125 throw new Error(`download failed: ${got.stderr.slice(0, 200)}`)
126 }
127
128 const sum = await $.process.run(['shasum', '-a', '256', part])
129
130 if (!sum.stdout.startsWith(JAR_SHA256)) {
131 await $.process.run(['rm', '-f', part])
132 throw new Error('the downloaded jar does not match its checksum')
133 }
134
135 await $.process.run(['mv', part, path])
136 }
137
138 state.isJarReady = true
139
140 return path
141}
142
143const check = async (
144 $: Engine,
145 jarPath: string,
146 id: string,
147 name: string,
148 before: string | undefined,
149 after: string,
150): Promise<Violation[] | undefined> => {
151 const dir = `${jarPath.slice(0, jarPath.lastIndexOf('/'))}/work/${id}`
152
153 try {
154 await $.fs.write(`${dir}/after/${name}`, after)
155
156 if (before !== undefined) {
157 await $.fs.write(`${dir}/before/${name}`, before)
158 }
159
160 const ran = await $.process.run(
161 [
162 'java', '-XX:TieredStopAtLevel=1', '-jar', jarPath, 'check',
163 '-d', dir, '-R', RULESET, '-f', 'json', '--no-cache', '--no-progress',
164 ],
165 { timeoutMs: 20_000 },
166 )
167
168 if (!PMD_OK.includes(ran.exitCode)) {
169 throw new Error(`pmd exited ${ran.exitCode}: ${ran.stderr.slice(0, 200)}`)
170 }
171
172 const report = JSON.parse(ran.stdout) as PmdReport
173 const isAfter = (path: string) => path.includes(`/${id}/after/`)
174
175 // A file that does not parse is the compiler's to refuse, not ours.
176 if (report.processingErrors?.some(one => isAfter(one.filename))) {
177 return undefined
178 }
179
180 const read = (wanted: boolean): Violation[] =>
181 (report.files ?? [])
182 .filter(file => isAfter(file.filename) === wanted)
183 .flatMap(file => file.violations)
184 .map(one => ({
185 rule: one.rule,
186 line: one.beginline,
187 priority: one.priority,
188 description: one.description,
189 }))
190
191 return addedViolations(read(false), read(true), state.blockPriority)
192 } finally {
193 await $.process.run(['rm', '-rf', dir]).catch(() => undefined)
194 }
195}
196
197const showCounts = ($: Engine) =>
198 $.ui.status(`p3c 拦 ${state.blocked} · 过 ${state.passed}`)
199
200/** Answers a refusal for the write, or undefined to let it through. */
201const judge = async (
202 $: Engine,
203 id: string,
204 filePath: string,
205 before: string | undefined,
206 after: string,
207): Promise<string | undefined> => {
208 try {
209 state.jar ??= fetchJar($)
210
211 // The first Java write of a machine starts the download and is not held for it.
212 if (!state.isJarReady) {
213 void state.jar.catch(() => {
214 state.jar = undefined
215 })
216 const ready = await Promise.race([
217 state.jar.then(() => true, () => false),
218 $.clock.sleep(1500).then(() => false),
219 ])
220
221 if (!ready) {
222 $.ui.status('p3c 正在准备规则引擎,本次未检查')
223
224 return undefined
225 }
226 }
227
228 const name = filePath.slice(filePath.lastIndexOf('/') + 1)
229 const added = await check($, await state.jar, id, name, before, after)
230
231 if (added === undefined || added.length === 0) {
232 state.passed += 1
233 showCounts($)
234
235 return undefined
236 }
237
238 state.blocked += 1
239 showCounts($)
240 const listed = added
241 .slice(0, MAX_LISTED)
242 .map(one => {
243 const fix = FIXES[one.rule]
244
245 return `- 第 ${one.line} 行 [${one.rule}] ${one.description}${fix === undefined ? '' : ` 改法:${fix}`}`
246 })
247 const more = added.length > MAX_LISTED
248 ? [`- 另有 ${added.length - MAX_LISTED} 处未列出`]
249 : []
250
251 return [
252 `p3c-guard:这次写入会给 ${name} 新增 ${added.length} 处阿里 Java 规约违规,未写入。`,
253 ...listed,
254 ...more,
255 '行号是写入后文件里的行号。请修正后重新写入;文件原有的违规不在此列。',
256 ].join('\n')
257 } catch (error) {
258 // A jar that went missing is fetched again by the next write.
259 state.jar = undefined
260 state.isJarReady = false
261 $.ui.status(`p3c 未检查:${String(error).slice(0, 60)}`)
262
263 return undefined
264 }
265}
266
267export const register: Register = (on, options) => {
268 state.blockPriority = Number(options?.blockPriority ?? '2') || 2
269 state.isEnabled = true
270 state.blocked = 0
271 state.passed = 0
272 state.jar = undefined
273 state.isJarReady = false
274
275 on('session.start', async ($, e, next) => {
276 await $.command.register({
277 name: 'p3c',
278 description: 'p3c-guard:/p3c 看状态,/p3c off 暂停,/p3c on 恢复',
279 })
280
281 return next(e)
282 })
283
284 on('command.run', { command: 'p3c' }, (_$, e) => {
285 const arg = e.args.trim()
286
287 if (arg === 'off' || arg === 'on') {
288 state.isEnabled = arg === 'on'
289 }
290
291 return {
292 text: `p3c-guard ${state.isEnabled ? '已开启' : '已暂停'}:拦截级别 ${state.blockPriority},本会话拦 ${state.blocked} 次、放行 ${state.passed} 次。`,
293 }
294 })
295
296 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
297 if (!state.isEnabled || !e.file_path.endsWith('.java')) {
298 return next(e)
299 }
300
301 const before = (await $.fs.exists(e.file_path))
302 ? await $.fs.read(e.file_path).catch(() => undefined)
303 : undefined
304 const deny = await judge($, e.tool_use_id, e.file_path, before, e.content)
305
306 return deny === undefined ? next(e) : { deny }
307 }).catch(($, e, next) => next(e))
308
309 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
310 if (!state.isEnabled || !e.file_path.endsWith('.java')) {
311 return next(e)
312 }
313
314 const before = await $.fs.read(e.file_path).catch(() => undefined)
315 const after = before === undefined
316 ? undefined
317 : applyEdit(before, e.old_string, e.new_string, e.replace_all === true)
318
319 // An edit that does not apply is the tool's own to refuse.
320 if (after === undefined) {
321 return next(e)
322 }
323
324 const deny = await judge($, e.tool_use_id, e.file_path, before, after)
325
326 return deny === undefined ? next(e) : { deny }
327 }).catch(($, e, next) => next(e))
328}
329