<!-- title: 造死代码侦探Skill：揪出未调用函数 -->

# Claude Code Skills 开发实战：造一个死代码侦探 Skill

## 概述

本教程带你从零构建一个名为 `dead-code-detect` 的 Claude Code Skill，它能扫描 Python 项目、构建调用关系图、揪出从未被调用的函数和孤立的导入项，并生成一份带置信度的风险报告。我们会完整走通一个"生产级"Skill 的全部要素：YAML frontmatter 配置、支撑文件（progressive disclosure）、动态上下文注入、预批准工具、可执行的 Python 分析脚本，以及安全第一的报告式设计。适合已经会用 Claude Code 但想掌握 Skill 进阶用法的开发者，预计动手时长 90 分钟。

## 前置条件

- 已安装 Claude Code（v2.1.196 及以上可使用 `${CLAUDE_PROJECT_DIR}` 等最新替换变量；老版本也兼容本教程核心部分）
- 一个待扫描的 Python 项目（没有的话，本教程会带你造一个含死代码的示例仓库）
- 基本的命令行与 Python 语法知识
- 了解 git 基础（用于验证 Skill 是否误删）

> 本教程的 Skill 只做"检测与报告"，不自动删除代码。删除交给人来决定——这是死代码工具最重要的设计原则。

## 第一步：理解 Skill 的本质——按需加载的过程性知识

很多人把 Skill 理解成"配置文件"，这会误导设计方向。官方文档说得很清楚：

> 当你把同一段指令、清单或多步流程反复粘贴进对话，或者 CLAUDE.md 里某一段已经长成"流程"而非"事实"时，就该把它抽成 Skill。与 CLAUDE.md 不同，Skill 的正文只在使用时才加载，所以冗长的参考资料在用之前几乎零成本。

这段话给出三个设计准则：

1. **Skill 是"动词"，不是"名词"。** 它描述一个动作或流程，而不是静态背景知识。
2. **按需加载是核心收益。** Skill 正文平时不进上下文，所以你可以放心写长指令，不必担心 token 成本——但**一旦加载，正文会留在整个会话**，每一行都是反复出现的 token 成本。
3. **Skill 与 CLAUDE.md 分工。** 事实放 CLAUDE.md，流程放 Skill。

死代码检测正是一个标准的"多步流程"：扫描目录 → 提取定义 → 构建引用图 → 计算入口集合 → 输出候选清单 → 生成报告。它不适合塞进 CLAUDE.md，因为大部分会话用不到它。

```mermaid
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/`）：

```bash
# 个人级：所有项目可用
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——frontmatter 与正文

`SKILL.md` 分两部分：顶部的 YAML frontmatter（告诉 Claude **何时**用），下面的 Markdown 正文（告诉 Claude **怎么**做）。先看完整文件：

```markdown
---
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).
```

### 逐字段拆解 frontmatter

| 字段 | 本例取值 | 作用 |
|------|----------|------|
| `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 看。

## 第四步：写真正的分析器——scripts/analyze.py

Skill 之所以强大，是因为它能调用外部脚本干重活。下面这个分析器用纯正则构建一个简易调用图：先收集所有 `def`/`class` 定义，再扫描全项目对这些名字的引用次数，引用为 0（排除自身定义处）的即为候选死代码。

```python
#!/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()
```

**预期输出**（对一个含死代码的项目运行时）：

```json
{
  "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 需要时读入：

```markdown
# 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 的秘诀）：

```markdown
# 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 可能被整段丢掉，需要重新触发。

## 实战案例：在真实 Python 项目上跑一遍

造一个带死代码的最小项目：

```bash
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` 导入，并对每项给出置信度和理由，最后建议你开分支确认后再删——而不是擅自删掉。

```mermaid
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
```

## 设计要点回顾

1. **报告式优先于执行式。** 死代码工具最大的风险是误删活代码。本 Skill 只检测报告，把删除决定权留给人。这点借镜了社区里成熟的 `remove-dead-code` Skill——它要求"先扫描出报告 → 人审阅 → 再删除 → 最后验证"四步分离。
2. **重活交给脚本。** 调用图分析这种确定性逻辑，用 Python 脚本远比让 LLM 逐文件推理更准、更省 token。Skill 的职责是编排（orchestrate）。
3. **渐进式披露。** SKILL.md 精简，详细规则下沉到 references，按需加载。
4. **动态上下文注入锁定实时性。** `!`bash 把分析结果内联进指令，保证 Claude 基于真实输出而非猜测来推理。
5. **frontmatter 精准触发。** `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/` 分支 |

## 延伸阅读

- **官方 Skills 文档**（frontmatter 全字段、替换变量、生命周期）：`https://code.claude.com/docs/en/skills`
- **SKILL.md frontmatter 字段逐项参考**：`https://www.agentpatterns.ai/tool-engineering/skill-frontmatter-reference/`
- **社区 remove-dead-code Skill**（多代理 + 备份分支的安全范式）：`https://github.com/qdhenry/Claude-Command-Suite/blob/main/.claude/skills/remove-dead-code/SKILL.md`
- **ast-grep 集成进 Claude Code Skill 的教程**（结构化代码搜索，可替代本例的正则）：`https://medium.com/@xuwei19850423`

## 参考来源

1. [Extend Claude with skills — Claude Code Docs](https://code.claude.com/docs/en/skills) — 官方 Skills 开发权威文档，含 frontmatter 全字段、字符串替换变量、Skill 内容生命周期
2. [SKILL.md Frontmatter Reference — AgentPatterns.ai](https://www.agentpatterns.ai/tool-engineering/skill-frontmatter-reference/) — 逐字段解释 name/description/allowed-tools/context 等配置项
3. [Claude-Command-Suite: remove-dead-code SKILL.md](https://github.com/qdhenry/Claude-Command-Suite/blob/main/.claude/skills/remove-dead-code/SKILL.md) — 真实生产级死代码 Skill，展示了安全优先的多代理扫描与备份分支范式
4. [Python Dead Code Detection | Claude Code Skill — mcpmarket](https://mcpmarket.com/tools/skills/python-dead-code-detection) — 社区 Python 死代码检测 Skill，检测策略参考
5. [How to Integrate the ast-grep Claude Skill Into Your Project — Medium](https://medium.com/@xuwei19850423) — 用 AST 结构化搜索替代正则，降低误报率的进阶方案

> [!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 等场景全覆盖。

![51学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
