Skip to content

🐛 TagTree 对特定 Rust 代码构造输出异常 #233

Description

@yyz159756

提交前确认 / Preflight

  • 我已搜索过 issues,没有重复 / I searched existing issues and found no duplicate
  • 我用的是最新版本(或已说明所用版本) / I'm on the latest version (or stated my version below)

版本 / Version

deepx 0.2.106

安装方式 / Install method

直接下载 release 二进制手动运行 / downloaded release binary, ran directly

操作系统 / OS

Windows

环境细节 / Environment detail

No response

问题描述 / What happened

现象

对大型 Rust 文件执行 TagTree 时,输出异常稀少——definition tag 只剩文件里第一个定义,其余(函数/结构体/枚举/impl)全部丢失,reference tag 也为 0。

实测对象:CodeWhale 项目 crates/cli/src/lib.rs(8739 行,317 KiB,无超长行、无生成标记):

输入 期望 v0.19.1 实际
整文件 数百个 definition 1 个(ProviderArg @32,文件第一个定义)
截断到 797 行 正常 21 个 ✅
截断到 798 行(加一行闭合 },) 正常 1 个
706-799 行片段单独解析 ≥2 个(start_lane/emit_control_receipt) 0 个
最小片段(pub fn + struct + impl) 3 个 3 个 ✅(基线正常)

定位:与文件体量无关,是特定语法树形态触发

1. 排除项(均实测排除)

候选原因 结论
单文件解析超时 ❌ 排除:解析仅 467ms(< 1s 超时);截断法同样触发
文件 >1 MiB ❌ 排除:317 KiB < 1 MiB
超长行(>40 KiB) ❌ 排除:最长行 243 字符
自动生成源码(>128 KiB + 生成标记) ❌ 排除:无生成标记
文件数/字节预算截断 ❌ 排除:706-799 段单独解析也触发

2. 按行截断的符号数(二分定位,全部同一文件前缀)

截断行 definition 数
50 0
200 4
500 10
600 16
700 19
795 21
796 21
797 21
798 1 ← 翻转点
800 / 1000 / 2000 / 4000 / 8739 1

翻转点:797→798 行。797 行是 None => println!("{}", serde_json::to_string_pretty(receipt)?),,798 行是闭合嵌套 match 的 },

最小触发构造(已验证,来自 lib.rs 779-800 行)

if json {
    match receipt.operation {
        codewhale_lane::ControlOperation::LaneList => {
            println!("{}", serde_json::to_string_pretty(&receipt.lane_records)?);
        }
        codewhale_lane::ControlOperation::LaneStatus => match receipt.lane_records.first() {
            Some(record) => println!("{}", serde_json::to_string_pretty(record)?),
            None if receipt.is_error() => {}
            None => println!("{}", serde_json::to_string_pretty(receipt)?),
        },   // ← 加上这一行闭合内层 match 后触发
        _ => println!("{}", serde_json::to_string_pretty(receipt)?),
    }
}

结构特征(三者同时出现时触发,单独/两两未验证):

  1. 嵌套 match(match 分支内再 match)
  2. match guard(None if receipt.is_error() => {})
  3. ? 操作符在 println! 宏参数内

注意:该片段单独解析(不带前文)输出 0 个 definition,但截断到 guard 前一行输出正常——证明与代码总量无关,是完整语法树形态(闭合嵌套 match)触发。

复现步骤 / Steps to reproduce

复现步骤

  1. 准备含上述构造的 Rust 源码(≥798 行,或用触发片段)
  2. tree := NewParser(lang).Parse(src)
  3. tags := NewTagger(lang, ResolveTagsQuery(entry)).TagTree(tree)
  4. 统计 strings.HasPrefix(tag.Kind, "definition.") 数量

截断法(稳定复现):对触发文件按行截断重跑,观察"闭合嵌套 match 的括号行"加入前后,definition 数从 N 骤变为 1。

期望行为

TagTree 应输出源码中全部定义的 tag,不因嵌套 match / guard / 宏内 ? 等合法语法而丢失任何 definition。

附加验证(修复对照)

版本 706-799 段 整文件 run_cli 定义
v0.19.1 0 1 ❌ 缺失
v0.48.1 1 301 ✅ 可抽出

升级到 v0.48.1 后症状消失(未向 v0.48.1 提交最小独立复现,但同一文件、同一调用链输出正常)。若维护者需要最小独立复现文件,可基于上述结构构造并配合截断法确认。


附录 A:复现测试代码(内联,可直接运行)

以下为 deepx codegraph 包内的复现测试(Go)。触发段内联在测试里,无需任何外部文件/路径,go test 直接运行。触发段为 CodeWhale crates/cli/src/lib.rs 706-799 行原文(start_lane 完整函数 + emit_control_receipt 截断部分);tree-sitter 语法解析不校验符号引用,可直接作为输入。注释中反引号已改为单引号(Go raw string 限制,不影响语法解析)。

package codegraph

import (
	"testing"
)

// TestMinimalRepro:复现 gotreesitter v0.19.1 TagTree 对 Rust 符号缺失。
func TestMinimalRepro(t *testing.T) {
	trigger := `fn start_lane(request: LaneStartRequest) -> Result<()> {
    use codewhale_lane::{
        LaneRegistry, LaneStartSpec, RuntimeBackendKind, WorktreeProvision, resolve_backend,
    };

    let LaneStartRequest {
        workflow, fleet, issue, goal, runtime, worktree_repo, branch,
        worktree_path, worktree_ttl_secs, command, environment, cwd,
    } = request;
    let kind = RuntimeBackendKind::parse(&runtime)?;
    let reg = LaneRegistry::open_default()?;
    let mut record = reg.create_pending(workflow, fleet, issue, goal, kind, worktree_ttl_secs)?;
    let worktree = match (worktree_repo, branch) {
        (Some(repo_root), Some(branch_name)) => {
            let path = worktree_path
                .unwrap_or_else(|| repo_root.join(".codewhale").join("lanes").join(&record.id));
            Some(WorktreeProvision { repo_root, branch: branch_name, path, base_ref: None })
        }
        (None, None) => None,
        _ => bail!("--worktree-repo and --branch must be provided together"),
    };
    let cmd = if command.is_empty() {
        vec!["sh".into(), "-c".into(), format!("echo lane {} started", record.id)]
    } else {
        command
    };
    let spec = LaneStartSpec {
        command: cmd, cwd, environment,
        log_proxy: (kind == RuntimeBackendKind::Tmux)
            .then(std::env::current_exe)
            .transpose()
            .context("resolve current Codewhale executable for tmux log proxy")?,
        worktree,
    };
    let backend = resolve_backend(kind);
    backend.start(&reg, &mut record, &spec)?;
    println!("started {}", record.id);
    println!("status:  {}", record.status.as_str());
    println!("runtime: {}", record.runtime.as_str());
    println!("log:     {}", record.log_path.display());
    if let Some(attach) = backend.attach_command(&record) {
        println!("attach:  {attach}");
    }
    Ok(())
}

/// Print one shared control receipt on the CLI surface.
///
/// The CLI does not format Lane control results itself: it renders the same
/// [codewhale_lane::ControlReceipt] the slash command and hotbar render, so
/// the three surfaces cannot drift in what they report (#1888).
fn emit_control_receipt(receipt: &codewhale_lane::ControlReceipt, json: bool) -> Result<()> {
    if json {
        // v0.9.2 compatibility: 'lane list --json' has always emitted an array
        // of 'LaneRecord', and 'lane status --json' a single one. Scripts
        // select '.[].id', '.worktree_path', '.log_path' off that shape.
        match receipt.operation {
            codewhale_lane::ControlOperation::LaneList => {
                println!("{}", serde_json::to_string_pretty(&receipt.lane_records)?);
            }
            codewhale_lane::ControlOperation::LaneStatus => match receipt.lane_records.first() {
                Some(record) => println!("{}", serde_json::to_string_pretty(record)?),
                None if receipt.is_error() => {}
                None => println!("{}", serde_json::to_string_pretty(receipt)?),
            },
            _ => println!("{}", serde_json::to_string_pretty(receipt)?),
        }
    } else if receipt.is_error() {
        eprintln!("{}", receipt.render());
    } else {
        println!("{}", receipt.render());
    }
}
`

	p := parserFor("x.rs") // rust parser;NewParser → Parse → NewTagger → TagTree
	if p == nil {
		t.Fatal("rust parser 未注册")
	}
	res, perr := p.Parse("x.rs", []byte(trigger))
	t.Logf("trigger segment symbols=%d err=%v", len(res.Symbols), perr)
	if len(res.Symbols) == 0 {
		t.Log("✅ bug 复现:触发段 0 个 definition")
	} else {
		t.Logf("❌ 未触发:期望 0,got %d", len(res.Symbols))
	}
}

说明:p.Parse 内部即 NewParser(lang)SetTimeoutMicrosParse(src)NewTagger(lang, ResolveTagsQuery(entry))TagTree(tree),随后按 tag.Kind 前缀 definition. 收集符号;parserFor("x.rs") 是扩展名 → parser 的路由。

报错信息 / 日志 / Error output / logs

## 附录 B:执行结果

### v0.19.1(复现)


=== RUN   TestMinimalRepro
    repro_test.go:119: trigger segment symbols=0 err=<nil>
    repro_test.go:124: ✅ bug 复现:触发段 0 个 definition
--- PASS: TestMinimalRepro (0.04s)
PASS
ok  	deepx/codegraph	1.469s


补充:整文件(8739 行)`TagTree` 原始输出(不经任何过滤):


total tags=1 defs=1 refs=0
#0 kind=definition.type name="ProviderArg"


### v0.48.1(对照,症状消失)


=== RUN   TestMinimalRepro
    repro_test.go:126: ❌ 未触发:期望 0,got 2
--- PASS: TestMinimalRepro (0.04s)
PASS
ok  	deepx/codegraph	1.420s

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions