本教程带你从零构建一个名为 dead-code-detect 的 Claude Code Skill,它能扫描 Python 项目、构建调用关系图、揪出从未被调用的函数和孤立的导入项,并生成一份带置信度的风险报告。我们会完整走通一个"生产级"Skill 的全部要素:YAML frontmatter 配置、支撑文件(progressive disclosure)、动态上下文注入、预批准工具、可执行的 Python 分析脚本,以及安全第一的报告式设计。适合已经会用 Claude Code 但想掌握 Skill 进阶用法的开发者,预计动手时长 90 分钟。
${CLAUDE_PROJECT_DIR} 等最新替换变量;老版本也兼容本教程核心部分)本教程的 Skill 只做"检测与报告",不自动删除代码。删除交给人来决定——这是死代码工具最重要的设计原则。
很多人把 Skill 理解成"配置文件",这会误导设计方向。官方文档说得很清楚:
当你把同一段指令、清单或多步流程反复粘贴进对话,或者 CLAUDE.md 里某一段已经长成"流程"而非"事实"时,就该把它抽成 Skill。与 CLAUDE.md 不同,Skill 的正文只在使用时才加载,所以冗长的参考资料在用之前几乎零成本。
这段话给出三个设计准则:
死代码检测正是一个标准的"多步流程":扫描目录 → 提取定义 → 构建引用图 → 计算入口集合 → 输出候选清单 → 生成报告。它不适合塞进 CLAUDE.md,因为大部分会话用不到它。
flowchart LR
A["用户提问<br/>'帮我找死代码'"] --> B{"Claude 读取<br/>description 判断相关性"}
B -->|相关| C["加载 SKILL.md 正文<br/>进入上下文"]
B -->|不相关| Z["不加载<br/>零 token 成本"]
C --> D["按正文执行流程"]
D --> E["调用 scripts/analyze.py"]
E --> F["产出报告供人决策"]
一个生产级 Skill 不只是一个 SKILL.md,而是一个目录。官方推荐的目录形态如下,我们把分析逻辑拆成一个独立的 Python 脚本,把"检测规则"这种长参考资料拆到单独文件:
dead-code-detect/
├── SKILL.md # 入口(必需,保持精简,<500 行)
├── references/
│ └── detection-rules.md # 详细检测规则(按需加载,不占常用上下文)
├── scripts/
│ └── analyze.py # 真正干活的调用图分析器
└── examples/
└── sample-report.md # 期望的报告格式样例
为什么要拆? 官方文档明确:SKILL.md 应控制在 500 行以内,详细的参考资料挪到独立文件,并在 SKILL.md 里用链接引用它们。这样 SKILL.md 入口精简,复杂的规则只在 Claude 判断需要时才被读入。这就是"渐进式披露"(progressive disclosure)。
执行下面的命令创建目录骨架(如果你在项目里加这个 Skill,就用项目级路径;想跨项目复用就用个人级路径 ~/.claude/skills/):
# 个人级:所有项目可用
mkdir -p ~/.claude/skills/dead-code-detect/{references,scripts,examples}
# 或项目级:仅当前项目可用(推荐先在项目里试)
mkdir -p .claude/skills/dead-code-detect/{references,scripts,examples}
| 位置 | 路径 | 作用范围 |
|---|---|---|
| 个人级 | ~/.claude/skills/<name>/SKILL.md |
你的所有项目 |
| 项目级 | .claude/skills/<name>/SKILL.md |
仅当前项目 |
| 企业级 | 托管设置 | 全组织 |
Skill 优先级:企业级 > 个人级 > 项目级。同名时高优先级覆盖低优先级。
SKILL.md 分两部分:顶部的 YAML frontmatter(告诉 Claude 何时用),下面的 Markdown 正文(告诉 Claude 怎么做)。先看完整文件:
---
description: Scans a Python project to find dead code — functions/methods never called and imports never used — and produces a risk-ranked report. Use when the user asks to find dead code, unused functions, orphaned imports, or wants to clean up / slim down the codebase.
allowed-tools: Bash(python3 *) Bash(pytest *) Read Grep Glob
paths: "**/*.py"
argument-hint: "[path-to-scan]"
---
## Your task
You are a dead-code detective. You DETECT and REPORT — you never delete code on your own.
Scan the target path (default: the current project root) and produce a dead-code report.
## Workflow
1. Run the analyzer. Execute the bundled script and capture its JSON:
```!bash
python3 "${CLAUDE_SKILL_DIR}/scripts/analyze.py" "${1:-${CLAUDE_PROJECT_DIR}}"
```
- `$1` is the first user argument (a path). If absent, default to the project root.
- `${CLAUDE_SKILL_DIR}` always points to this skill's own directory.
- The `!` prefix makes Claude Code run the command and inline its output HERE before you read the rest.
2. Interpret the output. The script prints JSON with two lists: `unreferenced_defs` and `unused_imports`. For each candidate, check for false positives (see references/detection-rules.md):
- Is it a test subject referenced only by test runners / fixtures?
- Is it exported in `__all__` or re-exported (a public API entry)?
- Is it referenced via a string, getattr, or dynamic dispatch the regex missed?
3. Write the report. Produce a Markdown report with this structure:
- A summary count table.
- A table of unreferenced definitions: `file:line`, name, type, confidence (high/medium/low), and a one-line reason.
- A table of unused imports.
- A "Do not touch" section listing anything that LOOKS dead but is actually live (entry points, __all__ exports, framework hooks).
4. Recommend next steps: suggest a branch + per-item confirmation before any removal. Do NOT remove anything.
## Safety rules (non-negotiable)
- NEVER delete files or symbols. Report only.
- ALWAYS treat `if __name__ == "__main__"`, CLI entry points, and `__all__` members as LIVE.
- ALWAYS treat names that appear only in strings, `getattr`, or config as POSSIBLY LIVE (confidence = low).
- When in doubt, keep it. False negatives beat false positives.
## Reference
For the full detection rules and false-positive patterns, load [references/detection-rules.md](references/detection-rules.md).
For an example report, see [examples/sample-report.md](examples/sample-report.md).
| 字段 | 本例取值 | 作用 |
|---|---|---|
description |
一句话说明 + 触发场景 | 唯一推荐字段。Claude 靠它判断何时自动加载。把核心用例放在最前面,因为它和 when_to_use 合并在技能列表里被截断到 1536 字符 |
allowed-tools |
Bash(python3 *) ... |
技能激活时这些工具免审批。注意:不限制可用工具池,只是免去逐次确认 |
paths |
**/*.py |
只在编辑 Python 文件时自动激活 |
argument-hint |
[path-to-scan] |
自动补全时显示的参数提示 |
关于 allowed-tools 的安全提醒: 项目级 .claude/skills/ 里的 allowed-tools 在你接受工作区信任对话框后才生效——和 .claude/settings.json 里的权限规则一样。审阅陌生仓库的 Skill 时要当心,因为一个 Skill 可以给自己授予很宽的工具权限。
正文里用到的 $1、${CLAUDE_SKILL_DIR}、${CLAUDE_PROJECT_DIR} 都是 Claude Code 内置替换变量:
| 变量 | 含义 |
|---|---|
$ARGUMENTS |
用户传入的全部参数 |
$1 / $ARGUMENTS[1] |
第 N 个参数(0 基) |
${CLAUDE_SKILL_DIR} |
本 Skill 的目录,用于引用自带脚本,与当前工作目录无关 |
${CLAUDE_PROJECT_DIR} |
项目根目录(v2.1.196+) |
Q:description 写多长合适? A:把"做什么 + 何时触发"压在前两句,别写背景故事。技能列表里超过 1536 字符会被截断。
Q:为什么不用 disable-model-invocation: true?
A:本例希望 Claude 在你说"帮我找找没用的代码"时自动触发,所以不设。如果你做的是 /deploy 这种有副作用、只想手动触发的技能,就加它。
Q:!bash 块和普通代码块有什么区别?
A:! 前缀触发动态上下文注入——Claude Code 会真的执行命令并把输出原地替换进正文,所以你读到的是带着实时分析结果的指令。普通代码块只是展示给 Claude 看。
Skill 之所以强大,是因为它能调用外部脚本干重活。下面这个分析器用纯正则构建一个简易调用图:先收集所有 def/class 定义,再扫描全项目对这些名字的引用次数,引用为 0(排除自身定义处)的即为候选死代码。
#!/usr/bin/env python3
"""Dead-code analyzer for Python projects.
Strategy: build a name->definition map, then count project-wide references.
Zero-reference names (excluding their own def site) are dead-code candidates.
"""
import ast
import json
import sys
from pathlib import Path
from collections import defaultdict
def collect_definitions(root: Path):
"""Return {fully_qualified_name: (file, lineno, kind)}."""
defs = {}
for py in root.rglob("*.py"):
if any(p in py.parts for p in ("venv", ".venv", "node_modules", "__pycache__", ".git")):
continue
try:
tree = ast.parse(py.read_text(encoding="utf-8"), filename=str(py))
except SyntaxError:
continue
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
defs[node.name] = (str(py), node.lineno, "function")
elif isinstance(node, ast.ClassDef):
defs[node.name] = (str(py), node.lineno, "class")
return defs
def count_references(root: Path, names: set):
"""Count how many times each name token appears across the project."""
refs = defaultdict(int)
for py in root.rglob("*.py"):
if any(p in py.parts for p in ("venv", ".venv", "node_modules", "__pycache__", ".git")):
continue
try:
tokens = set(ast.walk(ast.parse(py.read_text(encoding="utf-8"))))
except SyntaxError:
continue
# crude but effective: count name occurrences as attribute/id nodes
text = py.read_text(encoding="utf-8")
for name in names:
refs[name] += text.count(name)
return refs
def find_unused_imports(root: Path):
"""Find 'import X' / 'from m import Y' where the imported name is never used."""
unused = []
for py in root.rglob("*.py"):
if any(p in py.parts for p in ("venv", ".venv", "node_modules", "__pycache__", ".git")):
continue
try:
tree = ast.parse(py.read_text(encoding="utf-8"), filename=str(py))
except SyntaxError:
continue
imported = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
imported.add(alias.asname or alias.name.split(".")[0])
elif isinstance(node, ast.ImportFrom):
for alias in node.names:
if alias.name != "*":
imported.add(alias.asname or alias.name)
used = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)}
for name in imported:
if name not in used:
unused.append({"file": str(py), "line": 0, "name": name})
return unused
def main():
root = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd()
defs = collect_definitions(root)
names = set(defs.keys())
refs = count_references(root, names)
unreferenced = []
for name, (file, line, kind) in defs.items():
# a definition references its own name once at the def site
if refs.get(name, 0) <= 1 and not name.startswith("__"):
unreferenced.append({"file": file, "line": line, "name": name, "kind": kind})
print(json.dumps({
"root": str(root),
"unreferenced_defs": unreferenced,
"unused_imports": find_unused_imports(root),
}, indent=2))
if __name__ == "__main__":
main()
预期输出(对一个含死代码的项目运行时):
{
"root": "/home/me/demo-project",
"unreferenced_defs": [
{"file": "/home/me/demo-project/utils/legacy.py", "line": 12, "name": "old_formatter", "kind": "function"},
{"file": "/home/me/demo-project/services/v1.py", "line": 8, "name": "V1Client", "kind": "class"}
],
"unused_imports": [
{"file": "/home/me/demo-project/main.py", "line": 0, "name": "os"}
]
}
Q:为什么用 AST 而不是直接 grep?
A:AST 能区分"定义处"和"引用处",避免把函数定义行误算成一次引用。纯 grep 会对 def foo 和 foo() 一视同仁,产生误判。
Q:这个分析器会漏掉动态调用吗?
A:会。getattr(obj, "old_formatter") 这种字符串调度,AST 看不出 old_formatter 被引用。这正是我们在 SKILL.md 里加"安全规则"和低置信度标记的原因——分析器只管给候选,人(或 Claude 的二次确认)负责判断真死假死。
Q:脚本报 SyntaxError 怎么办?
A:代码里已用 try/except 跳过无法解析的文件,不会中断整个扫描。
references/detection-rules.md 承载详细规则,平时不进上下文,只在 Claude 需要时读入:
# Detection Rules & False-Positive Patterns
## High confidence (likely dead)
- Private helper (`_foo`) referenced nowhere outside its own module
- Standalone function never imported or called project-wide
## Medium confidence
- Public method on a class whose class itself is unreferenced
- Functions in modules marked deprecated
## Low confidence / DO NOT FLAG as dead
- Names in `__all__` (public API surface)
- `if __name__ == "__main__"` entry points
- CLI command handlers (Click/Typer/argparse targets)
- Framework hooks: Django `models.py` classes, FastAPI route handlers,
pytest fixtures, Celery tasks, SQLAlchemy models
- Names appearing in strings, configs, or `getattr()` (dynamic dispatch)
examples/sample-report.md 给一个报告样例,让 Claude 模仿格式(少描述、多示例是写 Skill 的秘诀):
# Dead Code Report — demo-project
| Category | Count |
|----------|-------|
| Unreferenced definitions | 2 |
| Unused imports | 1 |
## Unreferenced definitions
| Location | Name | Type | Confidence | Reason |
|----------|------|------|-----------|--------|
| utils/legacy.py:12 | old_formatter | function | high | no project-wide references |
| services/v1.py:8 | V1Client | class | medium | class unreferenced; verify not re-exported |
## Unused imports
| Location | Name |
|----------|------|
| main.py | os |
## Do not touch
- `cli.py:run` — Click entry point, appears live
官方文档强调:Skill 正文一旦加载就会留在整个会话,写完后要主动测试触发与质量。
1. 测试自动触发。 在一个 Python 项目里启动 claude,输入"帮我找找项目里的死代码"。Claude 应当因为 description 匹配而自动加载本 Skill 并执行。
2. 测试显式调用。 直接输入 /dead-code-detect ./src,$1 会被替换成 ./src。
3. 测试动态注入。 检查 !bash 块是否真的执行——你应能看到 analyze.py 的 JSON 输出被内联进上下文,而不是看到原始命令。
4. 验证误报。 故意造一个动态调用的死代码(其实没死),看 Claude 是否按"detection-rules"给出低置信度并放进"Do not touch"。
如果 Skill 用了一两次后"不再影响行为",官方的解释是:内容通常还在上下文里,是模型选择了别的工具。对策是强化 description 与指令,或用 hooks 把行为确定化。大规模压缩(compaction)后,Claude Code 会保留每个 Skill 最近一次调用的前 5000 token、合计 25000 token 预算——调用太多时老 Skill 可能被整段丢掉,需要重新触发。
造一个带死代码的最小项目:
mkdir -p demo-project/{utils,services} && cd demo-project
cat > main.py <<'EOF'
import os # 故意不用的导入
from utils.calc import add
print(add(1, 2))
EOF
cat > utils/calc.py <<'EOF'
def add(a, b):
return a + b
def old_formatter(x): # 从未被调用 —— 死代码
return f"[{x}]"
EOF
cat > services/v1.py <<'EOF'
class V1Client: # 整个类无人引用 —— 死代码
def fetch(self):
return "v1"
EOF
在这个目录下运行 claude,输入:
扫一下这个项目,找找哪些代码是死代码
预期结果: Claude 自动加载 dead-code-detect,执行 analyze.py,然后输出一份报告,准确标出 old_formatter、V1Client 和未用的 os 导入,并对每项给出置信度和理由,最后建议你开分支确认后再删——而不是擅自删掉。
flowchart TD
A["analyze.py 收集所有 def/class"] --> B["统计每个名字全项目引用次数"]
B --> C{"引用次数 <= 1 ?<br/>(排除定义自身)"}
C -->|是| D["加入 unreferenced 候选"]
C -->|否| E["判定为存活"]
D --> F["Claude 二次甄别<br/>__all__ / 入口点 / 动态调度"]
F --> G{"确认真死?"}
G -->|是| H["报告 high 置信度"]
G -->|存疑| I["报告 low / 列入 Do not touch"]
H --> J["建议开分支逐项确认"]
I --> J
remove-dead-code Skill——它要求"先扫描出报告 → 人审阅 → 再删除 → 最后验证"四步分离。!bash 把分析结果内联进指令,保证 Claude 基于真实输出而非猜测来推理。paths: **/*.py + 高质量 description,让 Skill 在对的时刻出现、其余时刻零成本。| 维度 | 评估 |
|---|---|
| 适合的训练营类型 | Claude Code 进阶营、AI 辅助代码治理营、研发效能训练营(中高级班) |
| 学员基础 | 已会用 Claude Code 完成基本任务,理解 git 与 Python |
| 预计教学时长 | 讲解 45 分钟 + 动手实验 45 分钟 = 90 分钟 |
| 可延伸作业 | (1) 扩展支持 JS/TS(改用正则或接入 ts-prune);(2) 加 context: fork 让分析在子代理中跑以隔离上下文;(3) 配一个 remove 子流程 Skill,要求先建 backup/ 分支 |
https://code.claude.com/docs/en/skillshttps://www.agentpatterns.ai/tool-engineering/skill-frontmatter-reference/https://github.com/qdhenry/Claude-Command-Suite/blob/main/.claude/skills/remove-dead-code/SKILL.mdhttps://medium.com/@xuwei19850423[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
