Claude Code 每次会话启动时会读取
CLAUDE.md,可以用@path导入其他文件,还可以在.claude/rules/里写只对部分路径生效的规则。这些写法里有很多“静默失效”的坑: 加了引号的导入不会生效,导入最多只能递归 4 跳,规则里只认paths一个字段,frontmatter 写坏了规则会变成全局加载。 这篇写一个memory_lint.py,把官方文档里的这些规则逐条做成检查。
先看清成品(box 上真实运行)
- 示例仓库查出 2 个 ERROR:一个规则文件 frontmatter 写坏了,一个导入指向的文件不存在。
- 另外 3 个提示:带引号的导入不会生效、规则里的
description字段会被忽略、.gitignore里没有CLAUDE.local.md。- 反引号里的
@README.md正确跳过,{ts,tsx}的花括号展开也处理正确。- pytest 3 项通过,其中包括“第 5 跳导入被拒绝”的测试。
合规提醒:Claude Code 需要 Claude 订阅或 Anthropic API Key,只能在 Anthropic 支持的国家和地区使用,不要用代理或共享账号绕过限制。代码和对话会发送给 Anthropic 处理,数据保留和训练政策见官方 Data usage 页面,公司代码先确认团队政策。
.claude/settings.json、.mcp.json、CLAUDE.md会提交进仓库,里面不要写密钥。本文不列价格,以官网为准。
运行说明:box 上没有安装 Claude Code CLI。官方安装脚本
curl -fsSL https://claude.ai/install.sh | bash在执行环境里被安全审查拦下,申请的批准也没有得到回复,所以我没有绕过它去安装。因此 Claude Code 实际是否加载这些文件、实际的行为,都没有在真实会话里验证。本文的检查规则全部依据官方文档(下面会标出处),脚本和测试都在 box 上真实运行过。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
CLAUDE.md、docs/*.md |
示例项目说明和导入链 |
.claude/rules/ |
四个规则:按路径生效、全局生效、多余字段、frontmatter 写坏 |
memory_lint.py |
检查脚本,有 ERROR 时退出码为 1 |
test_memory_lint.py |
3 项测试 |
需要准备哪些环境?
Python 3.9 以上,pip install pyyaml pytest。
官方规则(来自 memory 文档)
| 规则 | 文档原意 |
|---|---|
| 导入语法 | @path/to/import;相对路径相对于写导入的那个文件,而不是工作目录 |
| 递归深度 | 被导入的文件可以继续导入,最多 4 跳 |
| 不会被导入的情况 | 写在代码 span 或代码块里的;路径用引号包起来的 |
| 外部导入 | 指向工作目录之外的导入,第一次会弹窗要求批准 |
| 大小 | 每个 CLAUDE.md 建议少于 200 行 |
| 规则文件 | .claude/rules/ 下的 .md 文件,递归发现;没有 paths 的规则在启动时加载 |
| 规则字段 | frontmatter 里只读 paths(YAML 列表或逗号分隔的字符串),其他字段直接忽略,不会报错 |
| 写坏的 frontmatter | YAML 解析失败时,规则会被当作没有 paths 加载,也就是全局生效 |
CLAUDE.local.md |
个人设置,应该加进 .gitignore |
文件结构
57-claude-code-A-src/
├── CLAUDE.md
├── docs/{style.md, naming.md, api.md}
├── .claude/rules/{api.md, testing.md, broken.md, frontend/react.md}
├── src/api/orders.py, tests/test_orders.py, web/App.tsx # 空文件,供 paths 匹配
├── .gitignore
├── memory_lint.py
└── test_memory_lint.py
第 1 步:示例文件
# 订单服务
- 运行测试:`pytest -q`
- 代码风格见 @docs/style.md
- 接口约定见 @docs/api.md
- 部署说明见 "@docs/deploy.md"
- 不要导入的写法示例:`@README.md`
- 个人设置放在 CLAUDE.local.md(不提交)
<!-- .claude/rules/frontend/react.md -->
---
description: React 组件规范
paths: "web/**/*.{ts,tsx}"
---
# 组件
- 组件用函数式写法
<!-- .claude/rules/broken.md:少了一个 ] -->
---
paths: [src/**/*.py
---
# 这个 frontmatter 写坏了
第 2 步:检查脚本
"""按 Claude Code 官方 memory 文档检查 CLAUDE.md 与 .claude/rules/。退出码:有 ERROR 为 1。
依据(code.claude.com/docs/en/memory):
- @path 导入:相对路径相对“写导入的那个文件”;最多递归 4 跳;代码 span / 代码块里的不算;加引号的不会被导入
- 指向工作目录之外的导入是“外部导入”,首次需要人工批准
- 每个 CLAUDE.md 建议少于 200 行
- 规则文件:.claude/rules/**/*.md;frontmatter 里只读 paths(列表或逗号分隔字符串),其他字段被忽略;YAML 解析失败则当作无 paths 加载
- CLAUDE.local.md 应加入 .gitignore
"""
import fnmatch, pathlib, re, subprocess, sys
import yaml
ROOT = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
MAX_HOPS, MAX_LINES = 4, 200
IMPORT_RE = re.compile(r'(?<![\w`"])@((?:[^\s`"\\]|\\ )+)')
QUOTED_RE = re.compile(r'"@[^"\s]+"')
findings = []
def report(level, path, msg):
findings.append((level, str(path.relative_to(ROOT)) if path.is_relative_to(ROOT) else str(path), msg))
def strip_code(text):
text = re.sub(r"```.*?```", "", text, flags=re.S)
return re.sub(r"`[^`\n]*`", "", text)
def imports_of(path):
body = strip_code(path.read_text(encoding="utf-8"))
for q in QUOTED_RE.findall(body):
report("WARN", path, f"{q} 带引号,不会被导入")
body = QUOTED_RE.sub("", body)
return [m.replace("\\ ", " ") for m in IMPORT_RE.findall(body) if not m.startswith(("{", "/"))
and ("/" in m or "." in m)]
def walk(path, hop=0, chain=()):
if path in chain:
report("ERROR", path, "循环导入"); return
for raw in imports_of(path):
target = (path.parent / raw).resolve()
if not target.exists():
report("ERROR", path, f"导入 @{raw} 指向的文件不存在"); continue
if not target.is_relative_to(ROOT):
report("WARN", path, f"@{raw} 在工作目录之外(外部导入,首次需批准)")
if hop + 1 > MAX_HOPS:
report("ERROR", path, f"@{raw} 超过 {MAX_HOPS} 跳导入上限,不会被加载"); continue
walk(target, hop + 1, chain + (path,))
def expand_braces(p):
m = re.search(r"\{([^{}]*)\}", p)
return [x for alt in m.group(1).split(",") for x in expand_braces(p[:m.start()] + alt + p[m.end():])] if m else [p]
def glob_hits(pattern, files):
pats = expand_braces(pattern)
pats += [p.replace("**/", "") for p in pats if "**/" in p] # ** 也能匹配零层目录
return [f for f in files if any(fnmatch.fnmatch(f, p) for p in pats)]
def split_paths(s):
"""逗号分隔字符串,但花括号里的逗号属于 brace 展开,不能拆开。"""
out, buf, depth = [], "", 0
for ch in s:
depth += (ch == "{") - (ch == "}")
if ch == "," and depth == 0:
out.append(buf.strip()); buf = ""
else:
buf += ch
return [x for x in out + [buf.strip()] if x]
def check_rule(path, files):
text = path.read_text(encoding="utf-8")
m = re.match(r"^---\n(.*?)\n---\n", text, re.S)
if not m:
return "无条件加载"
try:
fm = yaml.safe_load(m.group(1)) or {}
except yaml.YAMLError as e:
report("ERROR", path, f"frontmatter YAML 解析失败,会被当作无 paths 规则全局加载:{str(e).splitlines()[0]}")
return "无条件加载(frontmatter 无效)"
for k in fm:
if k != "paths":
report("WARN", path, f"字段 {k} 会被忽略(规则只读 paths)")
paths = fm.get("paths")
if paths is None:
return "无条件加载"
paths = split_paths(paths) if isinstance(paths, str) else list(paths)
for p in paths:
if not glob_hits(p, files):
report("WARN", path, f"paths 模式 {p} 没有匹配到任何文件")
return "按路径加载:" + ", ".join(paths)
def main():
files = [str(p.relative_to(ROOT)) for p in ROOT.rglob("*") if p.is_file() and ".git" not in p.parts]
for md in [ROOT / "CLAUDE.md", ROOT / ".claude" / "CLAUDE.md"]:
if md.exists():
n = len(md.read_text(encoding="utf-8").splitlines())
if n > MAX_LINES:
report("WARN", md, f"{n} 行,官方建议少于 {MAX_LINES} 行")
walk(md)
rules = sorted((ROOT / ".claude" / "rules").rglob("*.md"))
print(f"{'规则':<34} 加载方式")
for r in rules:
print(f"{str(r.relative_to(ROOT)):<34} {check_rule(r, files)}")
gi = (ROOT / ".gitignore").read_text() if (ROOT / ".gitignore").exists() else ""
if "CLAUDE.local.md" not in gi:
report("INFO", ROOT / ".gitignore", "建议加入 CLAUDE.local.md,避免提交个人设置")
print()
for lvl, p, msg in sorted(findings, key=lambda x: (x[1], x[0])):
print(f"[{lvl}] {p}: {msg}")
return 1 if any(l == "ERROR" for l, _, _ in findings) else 0
if __name__ == "__main__":
sys.exit(main())
第 3 步:测试
import pathlib, subprocess, sys, textwrap
import pytest
LINT = pathlib.Path(__file__).with_name("memory_lint.py")
def run(root):
r = subprocess.run([sys.executable, str(LINT), str(root)], capture_output=True, text=True)
return r.returncode, r.stdout
def write(root, rel, text):
p = root / rel; p.parent.mkdir(parents=True, exist_ok=True); p.write_text(textwrap.dedent(text), encoding="utf-8")
def test_sample_repo():
code, out = run(LINT.parent)
assert code == 1
assert "broken.md: frontmatter YAML 解析失败" in out
assert "@examples/create-order.md 指向的文件不存在" in out
assert '"@docs/deploy.md" 带引号' in out
assert "README" not in out # 反引号里的 @README.md 不算导入
assert "按路径加载:web/**/*.{ts,tsx}" in out # 花括号里的逗号没被拆开
def test_import_hop_limit(tmp_path):
write(tmp_path, "CLAUDE.md", "@a1.md\n")
for i in range(1, 6):
write(tmp_path, f"a{i}.md", f"@a{i+1}.md\n" if i < 5 else "end\n")
code, out = run(tmp_path)
assert code == 1 and "@a5.md 超过 4 跳" in out
def test_clean_repo(tmp_path):
write(tmp_path, "CLAUDE.md", "见 @docs/x.md\n")
write(tmp_path, "docs/x.md", "ok\n")
write(tmp_path, ".gitignore", "CLAUDE.local.md\n")
code, out = run(tmp_path)
assert code == 0 and "[" not in out.split("\n\n", 1)[1]
第 4 步:运行
$ python memory_lint.py
规则 加载方式
.claude/rules/api.md 按路径加载:src/api/**/*.py
.claude/rules/broken.md 无条件加载(frontmatter 无效)
.claude/rules/frontend/react.md 按路径加载:web/**/*.{ts,tsx}
.claude/rules/testing.md 无条件加载
[ERROR] .claude/rules/broken.md: frontmatter YAML 解析失败,会被当作无 paths 规则全局加载:while parsing a flow sequence
[WARN] .claude/rules/frontend/react.md: 字段 description 会被忽略(规则只读 paths)
[INFO] .gitignore: 建议加入 CLAUDE.local.md,避免提交个人设置
[WARN] CLAUDE.md: "@docs/deploy.md" 带引号,不会被导入
[ERROR] docs/api.md: 导入 @examples/create-order.md 指向的文件不存在
$ echo $?
1
$ python -m pytest -q
3 passed in 0.11s
调试示例
花括号被逗号拆开。 react.md 里写的是逗号分隔的字符串 paths: "web/**/*.{ts,tsx}"。第一版脚本直接按逗号拆分,结果报了两个假警告:
[WARN] .claude/rules/frontend/react.md: paths 模式 web/**/*.{ts 没有匹配到任何文件
[WARN] .claude/rules/frontend/react.md: paths 模式 tsx} 没有匹配到任何文件
文档同时支持“逗号分隔的字符串”和“花括号展开”,花括号里的逗号属于展开语法,不能当分隔符。改成按括号深度拆分(split_paths)后,显示为 web/**/*.{ts,tsx},web/App.tsx 也能正确匹配到。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 导入的文件没有生效 | 路径加了引号,或者写在反引号里 | 去掉引号;路径里有空格就用 \ 转义 |
| 很深的说明没有加载 | 导入超过 4 跳 | 把导入链压平 |
| 按路径生效的规则变成了全局生效 | frontmatter 的 YAML 写坏了 | 用 claude --debug 看报错,或者用本文的脚本检查 |
规则里写的 description 没起作用 |
规则只读 paths |
说明写在正文里 |
| 个人设置被提交了 | 没有忽略 CLAUDE.local.md |
加进 .gitignore |
模板规则
- CLAUDE.md 只放全局要点,控制在 200 行以内;局部约定放进按路径生效的规则。
- 导入链不超过 4 跳,导入的目标必须存在。
- 把检查脚本放进 CI,规则写错就让构建失败。
项目资源
| 用途 | 地址 |
|---|---|
| Claude Code 文档索引(llms.txt) | https://code.claude.com/docs/llms.txt |
| Data usage | https://code.claude.com/docs/en/data-usage |
| Memory 文档(CLAUDE.md、导入、rules) | https://code.claude.com/docs/en/memory |
规则写对了,Claude 每次会话加载的就是团队想让它知道的内容,不多也不少。