SLOPSHOPPER

copy-lint

After a Write/Edit to public copy (globs in the repo's .claude/copy-lint.json), checks banned words/phrases and heading punctuation and hints the model; never…

newguard
A shopper browsing a rack in a slop shop
README

copy-lint

Lints public-facing copy after each Write/Edit against the repo's own rules and hints the model; never blocks the write.

Write / Edit 成功之后,如果目标文件算「对外文案」,就对写入的文本查:

  • 禁用词 / 短语(字面词或正则),带原因;
  • 标题(Markdown # 行,或 JSON 里 title / heading / headline / h1 / h2 开头的键)以指定标点结尾。

命中时把提示作为 context 交给模型去改;从不拦写入。代码标识符里的命中由模型自行判断忽略。

配置

规则全部来自仓库里的 .claude/copy-lint.json。从被写文件所在目录往上找,遇到 .git 停(所以 git worktree 里也生效)。没有配置文件、或 include 为空时,什么都不做。

键含义
includeglob 列表,相对 .claude/ 所在目录;命中的文件算对外文案。支持 **、*、?、{a,b}
exclude即使命中 include 也跳过的 glob(例如引用了禁用词的写作指南本身)
banned数组;元素是字符串(字面词),或 { "word": "...", "reason": "..." },或 { "pattern": "<正则>", "flags": "i", "reason": "..." }
headingNoTrailing标题不得以这些字符结尾,如 "。.";空串或省略则不查
guide可选,提示里让模型对照的写作指南路径

示例见 example.copy-lint.json,复制到你的仓库 .claude/copy-lint.json 再改。

测试

claude plugin test .

安装

/plugin marketplace add AbyssCN/claude-lead-harness
/plugin install copy-lint@claude-lead-harness
Source 2 files
hooks/register.ts 41 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { isCopyPath, lint, parseConfig } from './lint'
4import type { Config } from './lint'
5
6const CONFIG = '.claude/copy-lint.json'
7
8// The nearest `.claude/copy-lint.json` above the file, stopping at the repo root (a `.git`).
9async function findConfig($: EngineInterface, file: string): Promise<{ base: string; config: Config } | null> {
10  let dir = file.replace(/\/[^/]*$/, '')
11  while (dir !== '') {
12    const path = `${dir}/${CONFIG}`
13    if (await $.fs.exists(path)) {
14      const text = await $.fs.read(path).then(t => t as string, () => null)
15      const config = parseConfig(text)
16      return config ? { base: dir, config } : null
17    }
18    if (await $.fs.exists(`${dir}/.git`)) return null
19    dir = dir.replace(/\/[^/]*$/, '')
20  }
21  return null
22}
23
24export const register: Register = on => {
25  on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
26    const ran = await next(e)
27    if (ran.deny !== undefined || ran.isError === true || !e.file_path.startsWith('/')) return ran
28    const found = await findConfig($, e.file_path)
29    if (!found) return ran
30    const rel = e.file_path.slice(found.base.length + 1)
31    if (!isCopyPath(rel, found.config)) return ran
32    const text = e.tool === 'Write' ? e.content : e.new_string
33    const hits = lint(text, found.config)
34    if (hits.length === 0) return ran
35    const guide = found.config.guide ? `对照 ${found.config.guide} ` : ''
36    const note = `copy-lint(${e.file_path}):${hits.join(';')}。${guide}改掉;代码标识符里的不算。`
37    return { ...ran, context: [...(ran.context ?? []), note] }
38    // Only ever a hint: a fault here must not touch the write.
39  }).catch(($, e, next) => next(e))
40}
41
hooks/lint.ts 122 lines
1// Pure rules, kept apart so the tests can call them directly.
2// Everything product-specific comes from the repo's `.claude/copy-lint.json`.
3
4export type Banned = {
5  // A literal word or phrase (case-sensitive) ...
6  word?: string
7  // ... or a regular expression source, with `flags` (e.g. "i").
8  pattern?: string
9  flags?: string
10  // Why it is banned / what to write instead; shown to the model.
11  reason?: string
12}
13
14export type Config = {
15  // Globs, relative to the directory holding `.claude/`, of files that count as public copy.
16  include: string[]
17  // Globs excluded even when included (e.g. the style guide itself, which quotes the banned words).
18  exclude: string[]
19  banned: Banned[]
20  // Characters a heading must not end with, e.g. "。." ; empty turns the check off.
21  headingNoTrailing: string
22  // A doc the model should read before fixing, e.g. "docs/style-guide.md".
23  guide: string | null
24}
25
26const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [])
27
28// Null when the file is absent, broken or has nothing to check: the mod then does nothing.
29export function parseConfig(text: string | null): Config | null {
30  if (text === null) return null
31  let j: Record<string, unknown>
32  try {
33    j = JSON.parse(text) as Record<string, unknown>
34  } catch {
35    return null
36  }
37  if (typeof j !== 'object' || j === null) return null
38  const include = strings(j.include)
39  if (include.length === 0) return null
40  const banned = (Array.isArray(j.banned) ? j.banned : [])
41    .map((b): Banned | null => {
42      if (typeof b === 'string') return { word: b }
43      if (typeof b !== 'object' || b === null) return null
44      const o = b as Record<string, unknown>
45      const out: Banned = {}
46      if (typeof o.word === 'string' && o.word) out.word = o.word
47      if (typeof o.pattern === 'string' && o.pattern) out.pattern = o.pattern
48      if (typeof o.flags === 'string') out.flags = o.flags
49      if (typeof o.reason === 'string' && o.reason) out.reason = o.reason
50      return out.word || out.pattern ? out : null
51    })
52    .filter((b): b is Banned => b !== null)
53  return {
54    include,
55    exclude: strings(j.exclude),
56    banned,
57    headingNoTrailing: typeof j.headingNoTrailing === 'string' ? j.headingNoTrailing : '',
58    guide: typeof j.guide === 'string' && j.guide ? j.guide : null,
59  }
60}
61
62const escape = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
63
64// `**` any depth, `*` and `?` within one segment, `{a,b}` alternatives.
65export function globToRegExp(glob: string): RegExp {
66  let re = ''
67  for (let i = 0; i < glob.length; i++) {
68    const c = glob[i]!
69    if (c === '*') {
70      if (glob[i + 1] === '*') {
71        const slash = glob[i + 2] === '/'
72        re += slash ? '(?:.*/)?' : '.*'
73        i += slash ? 2 : 1
74      } else re += '[^/]*'
75    } else if (c === '?') re += '[^/]'
76    else if (c === '{') {
77      const end = glob.indexOf('}', i)
78      if (end < 0) re += '\\{'
79      else {
80        re += `(?:${glob.slice(i + 1, end).split(',').map(escape).join('|')})`
81        i = end
82      }
83    } else re += escape(c)
84  }
85  return new RegExp(`^${re}$`)
86}
87
88// `rel` is the path relative to the directory holding `.claude/`.
89export const isCopyPath = (rel: string, config: Config): boolean =>
90  config.include.some(g => globToRegExp(g).test(rel)) && !config.exclude.some(g => globToRegExp(g).test(rel))
91
92export function lint(text: string, config: Config): string[] {
93  const out: string[] = []
94  const found: string[] = []
95  for (const b of config.banned) {
96    let hit: string | null = null
97    if (b.word !== undefined) hit = text.includes(b.word) ? b.word : null
98    else if (b.pattern !== undefined) {
99      try {
100        const m = new RegExp(b.pattern, (b.flags ?? '').replace(/g/g, '')).exec(text)
101        hit = m ? m[0] : null
102      } catch {
103        hit = null
104      }
105    }
106    if (hit !== null) found.push(b.reason ? `「${hit}」(${b.reason})` : `「${hit}」`)
107  }
108  if (found.length > 0) out.push(`禁用词:${found.join('、')}`)
109
110  if (config.headingNoTrailing) {
111    const end = `[${escape(config.headingNoTrailing)}]`
112    const md = new RegExp(`^#{1,6}\\s.*${end}\\s*$`)
113    const json = new RegExp(`"(title|heading|headline|h1|h2)[^"]*"\\s*:\\s*"[^"]*${end}"`, 'i')
114    const headings = text
115      .split('\n')
116      .filter(l => md.test(l) || json.test(l))
117      .map(l => l.trim().slice(0, 40))
118    if (headings.length > 0) out.push(`标题以标点结尾:${headings.join(' | ')}`)
119  }
120  return out
121}
122