SLOPSHOPPER

p3c-guard

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

newguardcommandstatusprocess
v0.1.0MITupdated 2026-10-09alexlifexyz/p3c-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · p3c-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /p3c ⎿ p3c-guard: p3c-guard 已开启:拦截级别 2,本会话拦 0 次、放行 0 次。 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

p3c-guard

Agent 写 Java 时,违反《阿里巴巴 Java 开发手册》的代码落不了盘。

一个 Claude Code Mod。它在 Write / Edit 真正写入 *.java 之前跑一遍 p3c 规则,新增了违规就拒绝这次写入,并把规则、行号和改法交还给 Agent,Agent 改对了才能写进去。

English

效果

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」「代码审查」有什么不同

做法什么时候起作用
手册型 Skill把规约写成文档给模型读模型记得的时候
代码审查工具用模型审 diff写完之后,你想起来跑的时候
p3c-guard用语法树规则做确定性检查每一次写入之前

检查不经过模型,同样的代码每次结果一样,单个文件约 1 秒。

几个设计决定

  • 只拦新增的违规。 老项目的文件本来就有违规,全拦会让 Agent 一行都改不了。它对比写入前后的结果,只对多出来的那几条拒绝。
  • 默认只拦严重的。 规则分三级,默认拦第 1、2 级(线程池、线程安全、包装类比较、集合误用、命名等 32 条),第 3 级(缺 @author、魔法值、未指定集合容量等 25 条)不拦。
  • 自己出错时放行。 没装 Java、下载失败、规则引擎崩溃,都放行并在状态栏提示,不会把你的 Java 写入卡死。
  • 语法错误的文件放行。 那是编译器的事。

使用

装上就生效,状态栏显示 p3c 拦 2 · 过 14。

命令作用
/p3c查看状态和本会话的拦截次数
/p3c off暂停
/p3c on恢复

配置项 blockPriority(在 /config 里改):1 只拦最严重的,2 为默认,3 全拦。

已知限制

  • 不是整本手册。 只查 p3c 用代码实现了的 57 条规则。空 catch 块、日志规范、SQL 规约等没有对应规则的条目查不到。
  • 个别规则会漏。 例如静态 SimpleDateFormat 在 lambda 里调用时查不出。
  • 只看 Write 和 Edit。 Agent 用 shell 命令改文件时不经过它。
  • 每台机器的第一次 Java 写入不检查(规则引擎还在下载)。
  • 在 macOS、JDK 23、Claude Code 2.1.293 上测过。Linux 没测,Windows 不支持。
  • Claude Code Mods 是 2026-10-01 发布的早期接口,可能随版本变化。

规则从哪来

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 .

许可

MIT,见 LICENSE 和 NOTICE。

Source 1 files
hooks/register.ts 329 lines
1import 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