第 2 课
查符号、查调用链、跑 Cypher,再改一行代码算出影响面
让后台进程常驻,用 search_graph 按名字和关键词找符号;用 trace_path 理清 Flask 处理一次请求的调用链,避开「类名.方法名」查不到的坑;用 get_code_snippet 只读一个方法,用 search_code 做带归属的文本搜索;用 Cypher 统计调用次数、列出没有调用者的函数并弄清为什么不能直接删;最后改一行代码,用 detect_changes 和 check_index_coverage 算出影响面、判断索引是否过期。
课程目录第 2 / 3 课
学习位置仅保存在当前浏览器,有效期 180 天。
图文教材
本课对照的官方资料:README 的 CLI Mode、MCP Tools、Supported Cypher 与 Troubleshooting 部分(0.12.1 版) 。测试环境同课时 1:2026-11-14,Asia/Shanghai,Debian 13 x86_64,
codebase-memory-mcp0.12.1,练习仓库 Flask 3.1.3(项目名flask)。下面所有「对照结果」都是这台机器的真实输出,只做了节选。
上一课建好了 Flask 的图。这一课把最常用的查询工具挨个用一遍,顺序和编程智能体实际工作时差不多:先按名字找到符号,再查谁调用它、它调用谁,读它的源码,用 Cypher 做统计,最后改一行代码,让工具算出影响面。所有命令都在 ~/cbm-lab/flask 目录里执行。
第 1 步:让后台进程常驻,并定义一个简写
每条 cli 命令都要连接一个后台协调进程,没有就临时启动一个,命令结束再关掉。连续查询时先让它常驻,每条命令都能省下启动时间。再定义一个 shell 函数,后面少打几个字:
cd ~/cbm-lab/flask
codebase-memory-mcp daemon start
cbm() { codebase-memory-mcp cli "$@"; }
对照结果:
daemon: started (permanent, pid 3253981)
It survives idle periods and session ends; `codebase-memory-mcp daemon stop` retires it.
ui: warming asynchronously on configured port 9749
最后一行说明常驻进程同时在 9749 端口准备好了可视化界面,第 3 课再打开它。cbm 函数只在当前终端有效,新开终端要重新定义。
第 2 步:按名字和关键词找符号
search_graph 是入口。--name-pattern 用正则匹配名字,--label 限定节点类型,--file-pattern 限定文件路径:
cbm search_graph --project flask --name-pattern ".*dispatch_request.*" \
--label Method --file-pattern "src/**"
对照结果:
results: 4 (cols: qn label file lines in out)
flask.src.flask.app.Flask.dispatch_request Method src/flask/app.py 879-902 1 7
flask.src.flask.views.View.dispatch_request Method src/flask/views.py 78-83 0 1
flask.src.flask.views.MethodView.dispatch_request Method src/flask/views.py 182-191 1 5
flask.src.flask.app.Flask.full_dispatch_request Method src/flask/app.py 904-920 1 6
total: 4
每一列的意思:qn 是限定名(项目名 + 路径 + 类名 + 方法名),lines 是起止行号,in / out 是这个方法被多少处调用、它又调用了多少处。不知道确切名字时,用 --query 做关键词全文检索(BM25 排序,能拆开驼峰和下划线命名):
cbm search_graph --project flask --query "session cookie" --limit 5 \
--file-pattern "src/**" --format json | jq -r '.rows[] | "\(.[0]) \(.[3])"'
对照结果:
flask.src.flask.sessions.SessionInterface.get_cookie_name 171-173
flask.src.flask.sessions.SessionInterface.should_set_cookie 233-247
flask.src.flask.sessions.SessionInterface.get_cookie_httponly 195-200
flask.src.flask.sessions.SessionInterface.get_cookie_secure 202-206
flask.src.flask.sessions.SessionInterface.get_cookie_partitioned 215-221
不加 --file-pattern "src/**" 时一共命中 101 条,排在最前面的是 tests/ 里的测试函数;限定到 src/ 后剩 32 条,前 5 条都是 SessionInterface 里和 Cookie 相关的方法。
第 3 步:查调用链
trace_path 回答「谁调用了它」(inbound)和「它调用了谁」(outbound)。先试一个很自然、但会失败的写法:
cbm trace_path --project flask --function-name Flask.full_dispatch_request 2>&1 \
| jq -c '{error, function_name}'
对照结果:
{"error":"function not found","function_name":"Flask.full_dispatch_request"}
--function-name 要么写方法的短名字,要么写上一步查到的完整限定名,类名.方法名 这种半截写法查不到。错误信息里的提示也是让你先用 search_graph 查到确切名字。改用短名字,两个方向各查两层:
cbm trace_path --project flask --function-name full_dispatch_request \
--direction both --depth 2
对照结果:
function: full_dispatch_request
direction: both
callees_total: 12
flask.src.flask.app.Flask:
dispatch_request 1
ensure_sync 2
finalize_request 1
handle_http_exception 2
handle_user_exception 1
make_default_options_response 2
make_response 2
preprocess_request 1
process_response 2
raise_routing_exception 2
flask.src.flask.sansio.app.App:
_find_error_handler 2
trap_http_exception 2
callers_total: 2
callers: 2 (cols: qn hop)
flask.src.flask.app.Flask.wsgi_app 1
flask.src.flask.app.Flask.__call__ 2
数字是跳数:1 表示直接调用,2 表示隔了一层。从调用方这一侧读:WSGI 服务器调用 Flask.__call__,它调用 wsgi_app,wsgi_app 再调用 full_dispatch_request。被调用这一侧,第 1 跳的四个方法 preprocess_request、dispatch_request、handle_user_exception、finalize_request 正好是一次请求的四个阶段。默认不统计测试代码,要算上测试加 --include-tests true。
第 4 步:读源码,做文本搜索
确认了调用关系,再读源码。get_code_snippet 只返回这一个方法,不用打开 1500 多行的 app.py:
cbm get_code_snippet --project flask --qualified-name full_dispatch_request \
| sed -n "1,25p"
对照结果:
name: full_dispatch_request
qualified_name: flask.src.flask.app.Flask.full_dispatch_request
label: Method
file_path: /home/kunlab/cbm-lab/flask/src/flask/app.py
start_line: 904
end_line: 920
source_mode: full
source: |
def full_dispatch_request(self) -> Response:
...
self._got_first_request = True
try:
request_started.send(self, _async_wrapper=self.ensure_sync)
rv = self.preprocess_request()
if rv is None:
rv = self.dispatch_request()
except Exception as e:
rv = self.handle_user_exception(e)
return self.finalize_request(rv)
源码和上一步的调用链对得上。想找配置项、字符串这类不是符号的东西,用 search_code,它在已索引的文件里做 grep,并把命中行归到所在的函数或类。这里有个容易踩的坑:
cbm search_code --project flask --pattern SECRET_KEY --file-pattern "src/**" | head -n 3
cbm search_code --project flask --pattern SECRET_KEY --path-filter src/ --mode files \
| head -n 6
对照结果:
results: 0 (cols: qn label file lines matches matches_omitted in out)
total_grep_matches: 0
total_results: 0
files: 4
- src/flask/app.py
- src/flask/sessions.py
- src/flask/config.py
- src/flask/sansio/app.py
在 search_graph 里好用的 --file-pattern "src/**",放到 search_code 里一条都搜不到。测试下来,search_code 的 --file-pattern 匹配的是文件名(比如 "*.py"),要按目录限定得用 --path-filter。--mode files 只列出命中的文件;去掉它,会按函数和类列出命中行号。
第 5 步:用 Cypher 做统计
query_graph 支持 openCypher 的只读子集。先统计 src/ 里哪些方法被调用得最多:
cbm query_graph --project flask --query "
MATCH (c)-[:CALLS]->(f:Method)
WHERE f.file_path STARTS WITH 'src/'
RETURN f.name, count(c) AS callers ORDER BY callers DESC LIMIT 5"
对照结果:
rows: 5 (cols: f.name callers)
url_for "32"
get "18"
__init__ "11"
ensure_sync "11"
record_once "10"
再找「没有任何调用者」的函数。官方 README 也把这当作找死代码的例子,用 NOT EXISTS:
cbm query_graph --project flask --query "
MATCH (f:Function) WHERE f.file_path STARTS WITH 'src/flask/'
AND NOT EXISTS { (f)<-[:CALLS]-() }
RETURN f.name, f.file_path ORDER BY f.file_path, f.name LIMIT 8"
对照结果:
rows: 8 (cols: f.name f.file_path)
__getattr__ src/flask/__init__.py
_make_timedelta src/flask/app.py
_env_file_callback src/flask/cli.py
_path_is_ancestor src/flask/cli.py
_set_app src/flask/cli.py
_set_debug src/flask/cli.py
_validate_key src/flask/cli.py
routes_command src/flask/cli.py
src/flask/ 里一共有 20 个这样的函数,但它们不是死代码:__getattr__ 由 Python 在访问模块属性时自动调用,_set_app、_set_debug 是传给 Click 选项的回调函数,routes_command 靠装饰器注册成 flask routes 命令。再查三个大家天天用的公开函数:
cbm query_graph --project flask --query "
MATCH (f:Function) WHERE f.file_path STARTS WITH 'src/flask/'
AND NOT EXISTS { (f)<-[:CALLS]-() }
AND f.name IN ['url_for', 'after_this_request', 'has_app_context']
RETURN f.name, f.file_path"
cbm search_graph --project flask --name-pattern "^url_for$"
对照结果:
rows: 3 (cols: f.name f.file_path)
after_this_request src/flask/ctx.py
has_app_context src/flask/ctx.py
url_for src/flask/helpers.py
results: 2 (cols: qn label file lines in out)
flask.src.flask.app.Flask.url_for Method src/flask/app.py 1003-1127 35 8
flask.src.flask.helpers.url_for Function src/flask/helpers.py 195-246 0 1
flask.url_for 是给使用 Flask 的项目调用的,仓库内部自然没人调用它(in 为 0);它转手调用的 Flask.url_for 方法则有 35 处调用。所以「没有调用者」只能算候选名单。官方的说法也是:删之前要排查动态调用、装饰器、框架约定和对外公开的接口。
第 6 步:改一行代码,看影响面
detect_changes 把 git 里还没提交的改动映射到具体的函数,再沿调用关系往外找会受影响的地方。先在 preprocess_request 里加一行注释,模拟一次改动:
sed -i "/def preprocess_request(self)/a\ # demo" src/flask/app.py
git diff --stat
cbm check_index_coverage --project flask --paths '["src/flask/app.py"]' \
--format json | jq -c '.paths[] | {freshness, recommended_action}'
对照结果:
src/flask/app.py | 1 +
1 file changed, 1 insertion(+)
{"freshness":"metadata_changed","recommended_action":"read_source_and_reindex"}
check_index_coverage 已经发现文件在建索引之后被改过,建议先读源码、再重建索引。直接跑 detect_changes 会失败:
cbm detect_changes --project flask 2>&1 | fold -s -w 80
对照结果:
git revision resolution failed: no default base found (tried origin/HEAD, main,
master and the upstream; fell back to "main"), or HEAD is not a commit; pass
base_branch
它默认拿 origin/HEAD、main 或 master 当比较基准,而第 1 课按标签浅克隆的仓库一个分支都没有。用 --base-branch HEAD,表示和最近一次提交比较:
cbm detect_changes --project flask --base-branch HEAD --seeds list --limit 0 \
| sed -n "/^seeds:/,/^impacted_total:/p"
cbm detect_changes --project flask --base-branch HEAD --depth 3 --format json \
| jq -r '.impacted[] | select(.file | startswith("src/")) | "\(.hop) \(.qn)"'
对照结果:
seeds: 2 (cols: qn label file start end seed_reason)
flask.src.flask.app.Flask Class src/flask/app.py 81 1536 hunk
flask.src.flask.app.Flask.preprocess_request Method src/flask/app.py 1271 1296 hunk
impacted_total: 85
1 flask.src.flask.app.Flask.full_dispatch_request
2 flask.src.flask.app.Flask.wsgi_app
2 flask.src.flask.cli.ScriptInfo.load_app
3 flask.src.flask.app.Flask.__call__
3 flask.src.flask.cli.run_command
改动落在两个「种子」上:preprocess_request 方法本身,以及包着它的整个 Flask 类。因为 Flask 类在测试和示例里被创建了几十次,总影响数达到 85(查三层时是 88),大部分是测试函数,相当于告诉你「这些测试要重跑」。只看 src/ 的部分就清楚了:第 1 跳是 full_dispatch_request,再往外是 wsgi_app 和 __call__,和第 3 步查到的调用链一致。
确认完影响面,重建索引,让图跟上改动:
cbm index_repository --repo-path "$PWD" --name flask | jq -c '{project, status}'
cbm check_index_coverage --project flask --paths '["src/flask/app.py"]' \
--format json | jq -c '.paths[] | {freshness, recommended_action}'
对照结果:
{"project":"flask","status":"indexed"}
{"freshness":"metadata_match","recommended_action":"use_graph_with_best_effort_caveat"}
重建是增量的,只重新解析改过的文件。最后把演示改动撤掉,再重建一次:
git checkout src/flask/app.py
cbm index_repository --repo-path "$PWD" --name flask | jq -c '{project, status}'
对照结果:
Updated 1 path from the index
{"project":"flask","status":"indexed"}
常见坑
- trace_path 报 function not found:用短名字或
search_graph查到的完整限定名,别写类名.方法名。 - search_code 什么都搜不到:按目录限定要用
--path-filter src/;--file-pattern匹配的是文件名。 - detect_changes 报找不到基准分支:浅克隆、按标签检出或本地新建的仓库常见,加
--base-branch HEAD或你的主分支名。 - 影响面大得吓人:先用
--seeds list看改动落在哪些种子上。改到大类内部时整个类都会成为种子,可以用 jq 只看src/,或者用--seed-filter product去掉测试代码里的种子。 - 把「没有调用者」当成死代码直接删:回调、装饰器、魔术方法、对外接口都会出现在名单里,删之前逐个读源码确认。
- 改了代码却忘了重建:
check_index_coverage返回metadata_changed就说明图已经过期。重跑index_repository即可,是增量的,很快。
小结
这一课用 search_graph 按名字和关键词找到符号,用 trace_path 理清了 Flask 处理一次请求的调用链,用 get_code_snippet 只读需要的那一个方法,用 search_code 做带归属的文本搜索,用 Cypher 统计调用次数、列出没有调用者的函数,并知道了这份名单为什么不能直接拿来删代码。最后改一行代码,让 detect_changes 算出了影响面,再用 check_index_coverage 确认重建后图已跟上源码。下一课把这些能力交给编程智能体,并打开可视化界面 CBM Lens。
