开发技巧

Claude Code 每次会话启动时会读取 CLAUDE.md

Claude Code 每次会话启动时会读取 CLAUDE.md,可以用 @path 导入其他文件,还可以在 .claude/rules/ 里写只对部分路径生效的规则。这些写法里有很多“静默失效”的坑: 加了引号的导入不会生效,导入最多只能递归 4 跳,规则里只认 paths 一个字段,frontmatter 写坏了规则会变成全局加载。 这篇写一个 memo

BINBHO · 图文教程

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 每次会话加载的就是团队想让它知道的内容,不多也不少。