第 2 课

查符号、查调用链、跑 Cypher,再改一行代码算出影响面

让后台进程常驻,用 search_graph 按名字和关键词找符号;用 trace_path 理清 Flask 处理一次请求的调用链,避开「类名.方法名」查不到的坑;用 get_code_snippet 只读一个方法,用 search_code 做带归属的文本搜索;用 Cypher 统计调用次数、列出没有调用者的函数并弄清为什么不能直接删;最后改一行代码,用 detect_changes 和 check_index_coverage 算出影响面、判断索引是否过期。

图文30 分钟

课程目录第 2 / 3 课

学习位置仅保存在当前浏览器,有效期 180 天。

01 / 图文教材

图文教材

本课对照的官方资料:README 的 CLI Mode、MCP Tools、Supported Cypher 与 Troubleshooting 部分(0.12.1 版) 。测试环境同课时 1:2026-11-14,Asia/Shanghai,Debian 13 x86_64,codebase-memory-mcp 0.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。