首页 › tutorials › 造死代码侦探Skill:揪出未调用函数

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

概述

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

前置条件

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

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

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

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

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

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

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

逐字段拆解 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(排除自身定义处)的即为候选死代码。

#!/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 可能被整段丢掉,需要重新触发。

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

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

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

设计要点回顾

  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/ 分支

延伸阅读

参考来源

  1. Extend Claude with skills — Claude Code Docs — 官方 Skills 开发权威文档,含 frontmatter 全字段、字符串替换变量、Skill 内容生命周期
  2. SKILL.md Frontmatter Reference — AgentPatterns.ai — 逐字段解释 name/description/allowed-tools/context 等配置项
  3. Claude-Command-Suite: remove-dead-code SKILL.md — 真实生产级死代码 Skill,展示了安全优先的多代理扫描与备份分支范式
  4. Python Dead Code Detection | Claude Code Skill — mcpmarket — 社区 Python 死代码检测 Skill,检测策略参考
  5. How to Integrate the ast-grep Claude Skill Into Your Project — Medium — 用 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学通信知识星球海报-扫码加入