anatta-rs/ast-to-mermaid
GitHub: anatta-rs/ast-to-mermaid
一个基于 Tree-sitter 的 Rust 命令行工具,将代码 AST 转换为多层级 Mermaid 图表,支持 Git 感知的结构差异对比和函数调用时序可视化。
Stars: 3 | Forks: 1
# ast-to-mermaid
基于 Tree-sitter 的代码图构建器,可生成五个缩放级别的 [Mermaid](https://mermaid.js.org/) 图表(project / overview / module / function / impact),以及针对每个函数的 `sequenceDiagram` 视图和适用于下游图存储的 JSON artifact bundle。**Git 感知**:可在任意 ref 处渲染图表,生成按 commit 的 bundle,并对比分支间的结构性变更。
独立的 Rust crate。**没有数据库,没有图后端,没有 async runtime,没有内部框架耦合** —— 只有 tree-sitter、serde、clap 以及常用的错误/日志辅助工具。输入一个路径,即可输出 Mermaid 字符串(或包含 `.mmd` + `.meta.json` artifacts 的目录)。
以 git blob SHA-1 为键的内容寻址缓存使得分支切换成本极低:95% 以上的文件未更改 → 跳过解析,在 rust-analyzer(1500 个文件)上实现了 **38 倍热路径加速**。
## 在真实代码中查看
下方的每张图表都是此 crate 在其自身的 `src/` 上输出的真实 CLI 结果。长实体 ID(CLI 输出为 `code_src_some_module_rs__function__foo`)在某些标签中被缩短为尾段,以便在视觉上留出空间 —— 结构、边和计数均未修改。
### 全局视角:`a2m project ./src`
整个 crate 的概览 —— 每个顶层模块 + 跨模块调用计数:
```
graph TD
artifacts["artifacts — 1 mod, 9 fn, 2 struct"]
bin["bin — 1 mod, 2 fn, 1 struct"]
cache_rs["cache.rs — 1 mod, 5 fn, 5 struct"]
cli_support_rs["cli_support.rs — 1 mod, 12 fn, 7 struct"]
diff_rs["diff.rs — 1 mod, 4 fn, 7 struct"]
error_rs["error.rs — 1 mod, 0 fn, 0 struct"]
git_source_rs["git_source.rs — 1 mod, 5 fn, 1 struct"]
graph_["graph — 2 mod, 0 fn, 2 struct"]
lib_rs["lib.rs — 1 mod, 0 fn, 0 struct"]
model_rs["model.rs — 1 mod, 0 fn, 3 struct"]
parser["parser — 1 mod, 9 fn, 2 struct"]
pipeline_rs["pipeline.rs — 1 mod, 14 fn, 3 struct"]
render["render — 8 mod, 14 fn, 2 struct"]
resolve_rs["resolve.rs — 1 mod, 4 fn, 0 struct"]
artifacts -->|"1 calls"| render
bin -->|"6 calls"| cli_support_rs
cli_support_rs -->|"2 calls"| artifacts
cli_support_rs -->|"1 calls"| cache_rs
cli_support_rs -->|"2 calls"| diff_rs
cli_support_rs -->|"3 calls"| git_source_rs
cli_support_rs -->|"7 calls"| pipeline_rs
pipeline_rs -->|"1 calls"| artifacts
pipeline_rs -->|"4 calls"| git_source_rs
pipeline_rs -->|"2 calls"| parser
pipeline_rs -->|"1 calls"| render
pipeline_rs -->|"1 calls"| resolve_rs
```
(注意 `graph_` 节点 ID:`graph/` 模块的名称与 Mermaid 的 `graph TD` 关键字冲突,因此 `sanitize_id` 为其添加了 `_` 后缀。如果没有这种转义,图表将无法解析 —— 几乎每个输出目标语言的 codegen 工具都必须处理这种保留字防护。)
### 收敛性:`a2m impact ./src --target parse_phase`
对 `pipeline::parse_phase` 的更改会如何向外扩散?它是一个内部 helper,被 `analyze` 和 `bundle` 同时调用,这意味着 **每个**经过解析循环的子命令都会触及它 —— 通过五个不同的 cli_support 处理程序,其中 `run_diff` 通过 `ensure_indexed` 绕了两跳:
```
graph BT
parse_phase(("fn parse_phase (impacted)"))
main["main"]
run_analyze["run_analyze"]
run_bundle["run_bundle"]
run_index["run_index"]
run_diff["run_diff"]
ensure_indexed["ensure_indexed"]
analyze["analyze"]
bundle["bundle"]
main --> run_analyze
main --> run_bundle
main --> run_index
run_analyze --> analyze
run_bundle --> bundle
run_index --> bundle
run_diff --> ensure_indexed
ensure_indexed --> bundle
analyze --> parse_phase
bundle --> parse_phase
```
五个入口点,两个私有 helper,全部收敛于一个受影响的函数。这种影响范围可以将“重构这个安全吗?”变成一个 10 秒钟就能得出答案的问题。
### 分发器:`a2m module ./src --target render/mod.rs`
`render::render` 是一个被 **六个**模块共享的函数名。解析器通过 `use` 导入 + 限定调用路径进行消歧,因此分发扇出和两个真正的调用者都落在了正确的节点上。`impl` 块中的方法也是一等公民 —— 可以通过 `--target Type::method` 寻址(例如 `--target HnswBuilder::build`),无需拼写出泛型参数:
```
graph TD
subgraph render_mod["render/mod.rs"]
level(enum Level)
render["fn render"]
require_target["fn require_target"]
end
function_render(["render"])
impact_render(["render"])
module_render(["render"])
overview_render(["render"])
project_render(["render"])
emit_artifacts(["emit_artifacts"])
analyze(["analyze"])
render --> function_render
render --> impact_render
render --> module_render
render --> overview_render
render --> project_render
emit_artifacts --> render
analyze --> render
```
五个缩放级别,一个工具 —— `a2m overview` 和 `a2m function` 上面没有展示,但遵循相同的结构。
### 差异对比:`a2m diff ..`
两个已缓存 bundle 之间的集合差异,按更改类型着色,**在更改的实体之间绘制了边**,以便您查看影响范围 —— 不仅是 *什么* 发生了改变,还有 *这些更改是如何连接在一起的*。这是在本仓库上执行 `a2m diff 36f1585~..36f1585` 的真实输出(该 commit 教会了解析器通过 `use` 导入 + 限定路径来消除跨模块调用的歧义):
```
graph TD
%% diff: 36f1585~ → 36f1585
classDef added fill:#9f9,stroke:#0a0,color:#000
classDef removed fill:#f99,stroke:#a00,color:#000
classDef modified fill:#fb8,stroke:#d60,color:#000
classDef renamed fill:#9ff,stroke:#0aa,color:#000
%% added (11)
n0["parser/mod.rs::function::collect_use_paths"]:::added
n1["parser/mod.rs::function::extract_use_imports"]:::added
n2["resolve.rs::function::file_module_name"]:::added
n3["resolve.rs::function::split_call_name"]:::added
n4["tests/cross_module_resolution.rs"]:::added
n5["tests/…::bare_call_to_unique_name_still_resolves"]:::added
n6["tests/…::build_store"]:::added
n7["tests/…::qualified_inline_calls_dispatch_to_correct_sibling_module"]:::added
n8["tests/…::use_import_resolves_to_mod_dot_rs_when_name_is_ambiguous"]:::added
n9["extern:fs::read"]:::added
n10["extern:tempfile::tempdir"]:::added
%% modified (7)
n11["parser/mod.rs"]:::modified
n12["parser/mod.rs::function::CodeParser::parse_into"]:::modified
n13["parser/mod.rs::function::extract_calls"]:::modified
n14["parser/mod.rs::function::extract_item"]:::modified
n15["parser/mod.rs::impl::CodeParser"]:::modified
n16["resolve.rs"]:::modified
n17["resolve.rs::function::resolve_cross_module_calls"]:::modified
%% blast-radius edges (both endpoints in changeset)
n12 --> n1
n12 --> n14
n14 --> n13
n1 --> n0
n17 --> n3
n17 --> n2
n5 --> n6
n5 --> n10
n6 --> n9
n6 --> n17
n7 --> n6
n7 --> n10
n8 --> n6
n8 --> n10
```
`+11 -0 ~7 ↪0`。两个连接在一起的视觉聚类 —— 正是“提取 helper + 添加测试文件”重构的形态:
- **上半部分**是生产环境的重构:`parse_into`(已修改)现在调用 `extract_use_imports`(新增),后者又调用 `collect_use_paths`(新增)。`resolve_cross_module_calls`(已修改)增加了两个新的 helper(`split_call_name`、`file_module_name`)。八个橙色节点,四个绿色叶子节点。
- **下半部分**是测试层:`cross_module_resolution.rs` 是一个全新的测试文件。其中的三个测试共享了一个 `build_store` helper,该 helper 调用 `resolve_cross_module_calls` —— 这条单一的边是两个聚类之间的桥梁,告诉您 **新测试文件实际上测试了新的解析器代码**,而不是一些无关的路径。
- **出现了两个外部原子**(`fs::read`、`tempfile::tempdir`),因为新的测试文件引入了两个以前在任何地方都没有引用过的 stdlib + dev-dep 符号。
图的形状就是更改的形状。一个纯粹的 bug 修复会有一个橙色节点和一条连接红绿节点对的边。一个干净的重构将全是橙色,没有绿色/红色。一个带有测试的功能添加看起来正是这样 —— 一个紧密的生产环境聚类通过一两条边连接到测试聚类。
`--format json` 为下游工具返回结构化的 `BundleDiff`。重命名启发式算法将具有相同 `content_hash` 的(已移除、已添加)条目进行配对。对于任何尚未缓存的 ref,会自动运行 `a2m index`。
### 操作顺序:`a2m sequence ./src --target `
其他五个视图是无序的调用图 —— 它们告诉您 *谁调用了谁*,而不是 *按照什么顺序*。`a2m sequence` 按源码顺序遍历一个函数体,并生成 Mermaid `sequenceDiagram`:每个接收者一条生命线,每次调用一个箭头,控制流被提升到 `alt` / `loop` 块中。它适用于 Rust 和 Python —— `for`/`while`/`if`/`match` 提升为 `loop`/`alt`,Rust 的后缀 `.await` 和 Python 的前缀 `await` 都会标记为异步箭头。以本仓库缓存模块中的 `dir_size_recursive` 为例 —— 12 行 Rust 代码,一次树遍历:
```
fn dir_size_recursive(dir: &Path) -> Result {
let mut total = 0;
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let meta = entry.metadata()?;
if meta.is_dir() {
total += dir_size_recursive(&entry.path())?;
} else {
total += meta.len();
}
}
Ok(total)
}
```
`a2m sequence ./src --target dir_size_recursive` →
```
sequenceDiagram
autonumber
%% fn dir_size_recursive(dir: &Path) -> Result
participant self as self
participant entry as entry
participant meta as meta
loop for std::fs::read_dir(dir)?
self->>entry: metadata
alt if meta.is_dir()
self->>self: dir_size_recursive
self->>entry: path
else
self->>meta: len
end
end
```
一目了然整个算法:一个遍历目录条目的 `for` 循环,根据 `is_dir()` 进行分支 —— 递归调用(第 3 步的 `self` 循环)和叶子文件路径(`meta.len()`)。递归在视觉上是 `self` 生命线上的一条自指箭头;不同生命线(`meta`)上的 `else` 分支使得目录/文件的区分一目了然,无需阅读源码。
接收者的分类是基于语法的:`obj.method()` → `obj`,`Type::method()` → `Type`,纯标识符 → `self`。`.await` 对箭头进行标注。测试/panic 的底层逻辑(`assert!`、`Some`/`Ok` 构造函数等)会作为噪音被过滤掉。传入 `--all --out ` 可以为树中每个非空函数输出一个 `.mmd` 文件。
## 安装
```
cargo install ast-to-mermaid
```
这将安装一个名为 `a2m` 的二进制文件,包含 11 个子命令。从源码构建的方式也是如此:
```
cargo build --release
./target/release/a2m --help
```
## 快速开始
```
# 鸟瞰图:每个 crate/module + 跨 module 调用边(工作树)
a2m project ./my-repo
# 特定 git ref 下的相同图表 — 通过 `git ls-tree` / `cat-file` 读取,
# 无需 checkout。
a2m project ./my-repo --ref main
a2m project ./my-repo --ref v0.1.0
a2m project ./my-repo --ref HEAD~3
# 单个 module 的 items + 内部/跨 module 调用
a2m module ./my-repo --target src/server/handlers.rs
# 进入某个函数的逆向调用链(谁调用了它?)
a2m function ./my-repo --target parse_config
# 某个类型上的方法 — `Type::method` 简写会自动为你处理 generics
a2m function ./my-repo --target HnswBuilder::build
# 作为 Mermaid sequenceDiagram 的单个函数体(语句顺序)
a2m sequence ./my-repo --target run_diff
# 或者工作树中每个非空函数,每个 fn 生成一个 .mmd
a2m sequence ./my-repo --all --out ./diagrams
# 正向 + 反向影响(默认 3 跳)
a2m impact ./my-repo --target execute
# 写入文件而不是 stdout
a2m project ./my-repo --out graph.mmd
# 输出 Graphviz DOT 而不是 Mermaid — 适用于对浏览器渲染器来说过大的图
# (GitHub 限制为 500 条边,mermaid.live 在差不多的位置会卡死)。
# 直接 pipe 到 dot/sfdp/twopi/circo:
a2m project ./my-repo --format dot | dot -Tsvg > graph.svg
# 将某个 ref 的完整 bundle 物化到缓存中(幂等,重复运行
# 会打印缓存的路径并立即退出)
a2m index ./my-repo --ref main
# 两个 ref 之间的结构性 diff — 带颜色区分的 Mermaid 输出
a2m diff main..feature
# 在内置规则(target, node_modules, .git, dotfiles)的基础上跳过额外目录
a2m project ./my-repo --exclude vendor,generated
# 查看 parse / resolve 阶段的耗时 + cache 命中率
a2m project ./my-repo --trace=info
```
## 十一个子命令
| 子命令 | 输出 | 需要 `--target` |
|---|---|---|
| `a2m project` | 所有 crate + 跨 crate 调用计数 | 否 |
| `a2m overview` | 顶层模块 + 计数 (fn / struct / trait) + 跨模块边 | 否 |
| `a2m module` | 一个模块的条目 + 它们的调用者/被调用者,包括模块内和跨模块 | 是 — 模块路径或文件名 |
| `a2m function` | 单个函数及其调用者,回溯 N 跳 | 是 — 函数名 |
| `a2m impact` | 函数的前向 + 后向调用链(默认 3 跳) | 是 — 函数名 |
| `a2m sequence` | 将函数体作为 Mermaid `sequenceDiagram`(语句顺序,每个接收者一条生命线) | 是 — 函数名,或 `--all` |
| `a2m walk` | 列出路径下的源文件(无解析) —— 适用于 shell pipeline | 否 |
| `a2m bundle` | 完整的 4 层 artifact bundle(使用 `--with-sequences` 时包含 `+ sequences/`,见下文) | 否 — 需要 `--out` |
| `a2m index` | 将 git ref 的 bundle 物化到缓存中 (`./.a2m/cache/refs//`) | 否 — 默认为工作区 |
| `a2m diff` | 两个已缓存 bundle 之间的集合差异,带有颜色编码的 Mermaid 或 JSON | 是 — `..` |
| `a2m gc` | 根据 mtime + 软大小上限驱逐旧 / 超大的缓存条目 | 否 |
前八个命令接受 `--ref ` 以从任意 ref 读取,而不是工作区。最后三个命令接受 `--cache-dir ` 以重新定位缓存,并接受 `--no-cache` 以绕过缓存(临时的 tempdir)。
五个分析类子命令(`project`、`overview`、`module`、`function`、`impact`)也接受 `--format ` —— 参见 [当图对于 Mermaid 过大时](#when-the-graph-is-too-big-for-mermaid)。
## 当图对于 Mermaid 过大时
Mermaid 通过 dagre 在客户端渲染。浏览器 —— 以及 GitHub 的 markdown 渲染器 —— 将输入限制在 **500 条边 / 50 KB** 左右。超过这个限制,图表在结构上仍然是正确的,但无法查看:GitHub 会显示 `Edge limit exceeded`,mermaid.live 会卡死,SVG 画布会返回空白。
Graphviz 可以轻松处理 10k+ 的节点。`--format dot` 标志会输出 DOT 而不是 Mermaid,因此您可以直接将其 pipe 到您选择的布局引擎中:
```
# 分层布局(默认 — 最适合阅读依赖链)
a2m project ./my-repo --format dot | dot -Tsvg > graph.svg
# 力导向布局(最适合在大图中发现集群)
a2m project ./my-repo --format dot | sfdp -Tsvg > graph.svg
# 径向布局(一个中心节点向外发散)
a2m project ./my-repo --format dot | twopi -Tsvg > graph.svg
```
请先安装 graphviz(`brew install graphviz` / `apt install graphviz` / `choco install graphviz`)。
保留的内容:节点、ID、边、边标签、`subgraph` 边界(作为 DOT cluster)。不保留的内容:Mermaid 特定的节点形状(trait 为六边形,常量为圆柱体等)会折叠为 DOT 默认的矩形。连接性得以保留;排版提示会丢失。这就是针对 *“否则图将无法查看”* 所做的权衡。
## Artifact bundle
`a2m bundle` 写入的是一个结构化的目录,而不是单一的 Mermaid 字符串 —— 每个实体都有自己的 `.mmd` 和 `.meta.json`,外加一个主 `index.json`:
```
a2m bundle ./src --out ./.artifacts
```
```
.artifacts/
├── overview.mmd # project-level diagram
├── index.json # schema=2, every entity (id, kind, content_hash, edges)
└── entities/
├── code_src_pipeline.rs.mmd # the module
├── code_src_pipeline.rs.meta.json # ↳ children, hash, ...
├── code_src_pipeline.rs__function__analyze.mmd # one function
└── code_src_pipeline.rs__function__analyze.meta.json # ↳ callers, callees, line range, signature, doc
```
每个 `.meta.json` 都包含实体的 ID、种类、文件/行范围、签名、文档、SHA-256 内容哈希,以及完整的边结构 —— `callers`、`callees`,以及用于 impl/trait 对的 `implements` / `implemented_by`。调用所分析的树之外的 crate(例如 `serde_json::to_string`、`divan::main`)在 bundle 中会变成合成的 `extern:` 原子,这样外部依赖性就会在边界处可见,而不是悄悄消失。
该 bundle 是纯 JSON + Mermaid —— 无需重新解析即可将其加载到任何图存储(Neo4j、DuckDB、内存中)。
### 可选:按函数划分的序列图
传入 `--with-sequences`,bundle 就会获得第五层 —— 为每个函数体至少包含一次调用的 Rust 函数生成一个 Mermaid `sequenceDiagram`:
```
a2m bundle ./src --out ./.artifacts --with-sequences
```
```
.artifacts/
├── overview.mmd
├── index.json # function entries gain `sequence_path: "sequences/.mmd"`
├── entities/...
└── sequences/
└── code_src_pipeline.rs__function__analyze.mmd # statement-order body view, lifelines per receiver
```
默认关闭 —— 提取序列会使用 tree-sitter 访问者重新解析每个 Rust 文件,并使 bundle 的实际运行耗时大致增加一倍。具有空函数体的函数(getter、`unimplemented!()`、仅包含文档的存根)将被跳过,因此该层在真实的代码库中会保持稀疏。
`content_hash` 是实体源码片段的 **git blob SHA-1** —— 与 `git hash-object` 产生的值相同。缓存键、跨分支去重以及 `a2m diff` 重命名启发式算法都依赖于这个身份标识。
## Git 感知模式 + 缓存
`a2m` 在 `/.a2m/cache/` 维护着一个内容寻址缓存。该目录在首次运行时创建,并自动添加到 gitignore(如果不存在,则写入单行的 `.a2m/.gitignore`),其结构如下:
```
.a2m/cache/
├── version # schema + grammar + a2m versions; mismatch wipes
├── blobs/
│ └── .cbor # parse output for one file blob
└── refs/
└── / # one materialized bundle per ref
├── overview.mmd
├── index.json
└── entities/...
```
两层结构,两个收益:
- **`blobs/.cbor`** —— 按文件原子去重。切换分支 → 仅需重新解析更改的 blob。在 rust-analyzer 热路径上测得 38倍的解析阶段加速。
- **`refs//`** —— 在相同的 ref 上复用整个 bundle。在已缓存的 commit 上重新运行 `a2m index` 会打印路径并在约 50 毫秒内退出。
### 工作流示例
```
# CI:每次 commit 物化一次,下游 job 直接读取 bundle
a2m index ./repo --ref "$GITHUB_SHA"
cp -r .a2m/cache/refs/"$GITHUB_SHA"/ ./pr-graph/
# Dev loop:查看两个 PR ref 之间在结构上的变化
a2m diff main..feature/cache-rewrite
# 清理缓存(默认 1 GB 软限制)
a2m gc --max-size 500M --dry-run # plan
a2m gc --max-size 500M # execute
# 冷路径 benchmark(无持久化 cache)
a2m project ./repo --no-cache --trace=info
```
### 追踪
`--trace=info` 输出结构化的按阶段耗时:
```
INFO parse_phase{files=1464}: parse_phase done parsed=1464 atoms=15867 hits=1464 misses=0 elapsed_ms=42
INFO resolve_phase{atoms=15867}: resolve_phase done edges=5924 elapsed_ms=98
```
`hits` / `misses` 计数器会准确告诉您原子缓存节省了多少工作。
## 语言
- **Rust** — `tree-sitter-rust`
- **Python** — `tree-sitter-python`
其他任何语言在遍历期间都会被静默跳过。解析器是 **查询驱动** 的:每种受支持的语言在 `src/parser/queries//` 中都会有一小套 `.scm` tree-sitter 查询文件(items、calls、uses、impl-methods)。添加一种语言大致上是:添加 `tree-sitter-` 依赖,放入查询文件,添加一个 `Language` 枚举变体。不需要为每种语言编写新的遍历代码。
## 作为库使用
添加到您的 `Cargo.toml`:
```
[dependencies]
ast-to-mermaid = "0.2"
```
```
use ast_to_mermaid::artifacts::write_artifacts;
use ast_to_mermaid::pipeline::{AnalyzeOptions, analyze, bundle};
use ast_to_mermaid::render::Level;
use std::path::Path;
fn main() -> Result<(), Box> {
// Render a single Mermaid string at a given level.
let mut opts = AnalyzeOptions::default();
opts.level = Level::Overview;
let report = analyze(Path::new("./my-repo"), &opts)?;
println!("{}", report.mermaid);
// Or build the full artifact bundle.
let (artifacts, _report) = bundle(Path::new("./my-repo"), &AnalyzeOptions::default())?;
write_artifacts(&artifacts, Path::new("./.artifacts"))?;
Ok(())
}
```
较低级别的组件是公开的,供希望手动驱动 pipeline 的嵌入者使用:`parser::{CodeParser, Language}`、`graph::Store`、`resolve::{resolve_cross_module_calls, resolve_implements_edges, EXTERN_KIND}`、`render::{render, Level}`、`pipeline::{bundle, DEFAULT_EXCLUDED_DIRS}`。
## 工作原理
```
collect inputs (FS walk OR `git ls-tree --full-tree `)
└─ src/git_source shell-out to git rev-parse / ls-tree / cat-file
└─ src/cache per-blob: blobs/.cbor (cbor)
per-ref: refs// (full bundle)
└─ src/parser tree-sitter → ParseUnit { atoms, edges }
(intra-file Calls + Contains edges)
└─ src/graph in-memory Store
└─ src/resolve cross-module Calls edges (file-scope `use`
imports + qualified call paths to disambiguate
same-named functions across modules)
└─ src/render Mermaid string per zoom level
└─ src/artifacts emit_artifacts → ArtifactSet → write_artifacts
└─ src/diff compute_diff(BundleA, BundleB) →
BundleDiff (added/removed/modified/renamed)
+ render_mermaid (colour-coded)
```
没有 async,没有持久层,没有图后端。缓存是磁盘上纯 CBOR + JSON 文件。内存中的 `Store` 是一个 `RwLock`,并且只在一次 CLI 调用期间存在。
## 质量检测
```
make check # fmt + clippy (pedantic) + test
make ci # check + coverage-gate
make coverage-gate # fail if line coverage < 95 %
make hooks # install pre-commit + pre-push hooks (.githooks/)
```
CI 会在每个 PR 上运行 `make ci`。覆盖率检测会忽略 `bin/*.rs`(这些是简单的包装器 —— 被测试的是库本身)。
## 状态
`v0.2` —— Git 感知。十个子命令(七个渲染级别 + `index` / `diff` / `gc`),库 API,artifact bundle,以 git blob SHA-1 为键的两层内容寻址缓存。`index.json` 存在破坏性更改(`schema: 2`,新增 `content_hash` 字段,值格式从 `sha256:` 更改为原始的 git blob SHA-1)。已在 6 到 1463 个文件(rust-analyzer)的 Rust crate 上进行了测试;基准测试请参见 [`docs/perf/2026-05-01-resolve-cost-baseline.md`](./docs/perf/2026-05-01-resolve-cost-baseline.md)。
未来工作:针对大型 monorepo 的冷路径引入并行解析循环(`rayon`),如果 `--trace=info` 显示在实际工作负载中解析阶段耗时超过了总耗时的 30%,则考虑可选的 V2 级别缓存(目前即使在 rust-analyzer 规模下也 ≤ 7%)。
## 示例(本仓库的真实输出)
### Atom cache:冷启动 → 热启动 → 单文件编辑
在此 crate 上连续执行三次 `a2m project ./src --trace=info`。缓存状态可通过 `hits` / `misses` 查看:
```
# Run 1 — 冷缓存,每个文件均为未命中
INFO parse_phase{files=22}: parse_phase done parsed=22 atoms=173 hits=0 misses=22 elapsed_ms=23
INFO resolve_phase{atoms=173}: resolve_phase done edges=50 elapsed_ms=0
# Run 2 — 热缓存,每个文件从磁盘重放,完全跳过 parser
INFO parse_phase{files=22}: parse_phase done parsed=22 atoms=173 hits=22 misses=0 elapsed_ms=0
INFO resolve_phase{atoms=173}: resolve_phase done edges=50 elapsed_ms=0
# Run 3 — 修改了一个文件;仅该 blob 被重新 parse,其他 21 个被重用
INFO parse_phase{files=22}: parse_phase done parsed=22 atoms=173 hits=21 misses=1 elapsed_ms=1
INFO resolve_phase{atoms=173}: resolve_phase done edges=50 elapsed_ms=0
```
该模式可扩展:在 rust-analyzer 上(1464 个文件 / 570 k LOC),热解析阶段的时间从 1432 毫秒降至 42 毫秒 —— **38 倍加速** —— 参见 [`docs/perf/2026-05-01-resolve-cost-baseline.md`](./docs/perf/2026-05-01-resolve-cost-baseline.md)。
### `a2m diff 0ee4cae..0ddc266` —— 原子写入 commit
构建此 README 的分支上两个 commit 之间的真实差异对比。原子写入 commit 添加了 `atomic_write` / `atomic_rename` helper 和 `write_bundle_atomic` CLI helper,然后修改了 `ensure_indexed` 和 `run_index` 以调用它们:
```
graph TD
%% diff: 0ee4cae → 0ddc266
classDef added fill:#9f9,stroke:#0a0,color:#000
classDef removed fill:#f99,stroke:#a00,color:#000
classDef modified fill:#fb8,stroke:#d60,color:#000
classDef renamed fill:#9ff,stroke:#0aa,color:#000
n0["cache.rs::const::BLOB_ENVELOPE_VERSION"]:::added
n1["cache.rs::const::BLOB_MAGIC"]:::added
n2["cache.rs::function::atomic_rename"]:::added
n3["cache.rs::function::atomic_write"]:::added
n4["cache.rs::struct::BlobEnvelope"]:::added
n5["cli_support.rs::function::write_bundle_atomic"]:::added
n6["cache.rs"]:::modified
n7["cache.rs::impl::Cache"]:::modified
n8["cli_support.rs"]:::modified
n9["cli_support.rs::function::ensure_indexed"]:::modified
n10["cli_support.rs::function::run_index"]:::modified
%% blast-radius edges (both endpoints in changeset)
n9 --> n5
n10 --> n5
n5 --> n2
```
这些箭头直接说明了情况:两个 **已修改** 的函数(橙色)都新增了对新的 `write_bundle_atomic` helper(绿色)的调用,而后者又调用了新的 `atomic_rename` helper(绿色)。如果没有这些边,同样的差异对比仅仅是一个带颜色编码的列表 —— 有用但却无法体现调用关系。有了它们,您就能看出为什么这些修改是必要的。`+6 -0 ~5 ↪0`。
### `a2m diff v0.1.0..HEAD` —— 整个 git 感知的历程
最后一个版本与此分支 HEAD 之间累积差异的统计数据:
```
diff v0.1.0 → HEAD: +63 -24 ~45 ↪0
```
新增了 63 个实体(新的 `cache`、`diff`、`git_source` 模块 + 它们的公开 API + 新的子命令处理程序),移除了 24 个(FNV-1a `hex_sha256`,被合并的七个独立二进制入口点),修改了 45 个(每个现有模块都增加了 `--ref` 管道,解析器被重构以公开 `ParseUnit`,artifact 发射器现在会写入 `content_hash`)。
将完整的 Mermaid 输出粘贴到 [mermaid.live](https://mermaid.live) 中,以直观地滚动浏览颜色编码的实体列表。
### `a2m index --ref` 和重新运行
```
$ a2m index ./repo --ref main
indexed 8209bc8315459f3534c501b0d1607d2b84470fcd → /repo/.a2m/cache/refs/8209bc8.../bundle (22 files, 153 atoms, 47 edges)
$ a2m index ./repo --ref main
cached 8209bc8315459f3534c501b0d1607d2b84470fcd → /repo/.a2m/cache/refs/8209bc8.../bundle
$ a2m index ./repo --ref main --force
indexed 8209bc8315459f3534c501b0d1607d2b84470fcd → /repo/.a2m/cache/refs/8209bc8.../bundle (22 files, 153 atoms, 47 edges)
```
默认具有幂等性:在同一个 commit 上重新运行会打印出缓存的路径,并在几毫秒内退出。`--force` 会重新物化(例如在解析器版本升级之后)。
### `a2m gc` —— 修剪缓存
```
$ a2m gc --max-size 100K --dry-run
would remove 1 entries (235830 bytes) from /repo/.a2m/cache (had 1 entries, 235830 bytes)
$ a2m gc --max-size 100K
removed 1 entries (235830 bytes) from /repo/.a2m/cache (had 1 entries, 235830 bytes)
```
驱逐是按照 mtime 升序进行的,直到总大小低于上限。`--older-than 30d` 会在此之上增加一个时间过滤条件。
## 许可证
[Apache-2.0](./LICENSE)
标签:Mermaid, Rust, Tree-sitter, 云安全监控, 代码可视化, 可视化界面, 网络流量审计, 通知系统, 静态分析