package diag import ( "go/ast" "go/parser" "go/token" "os" "path/filepath" "strings" "testing" ) // TestNoDirectTerminalWrites:TUI 跑在 alt-screen 下,**任何**直写 os.Stdout/os.Stderr // 都会打穿界面。工具可达的包(工具实现、协议客户端、调度器……)都拿不到 agent 的 // Opt.Stderr,所以很容易顺手 fmt.Fprintf(os.Stderr, ...) 或 log.Printf——这条约定以前 // 只写在注释里,靠人记得,而实际就漏过好几处: // // - webui 的 11 处 log.Printf(默认 logger 直写 os.Stderr;而 WebUI 就在 TUI 里跑, // SetLogOutput 只接管了 gin 的 writer); // - MCP 子进程的 stderr 直连终端(而 MCP 的惯例恰恰是往 stderr 打日志); // - computer 工具的三处区域截图警告与 Windows 子进程 stderr。 // // 所以做成静态检查:工具可达包里出现 os.Stdout / os.Stderr / fmt.Print(log|f)( // 就失败。诊断请走 diag;子进程的 stderr 用 diag.Indirect()。 // // 用 go/ast 而不是 grep:注释里也会出现 "os.Stderr" 字样(本来就是在讲这件事), // 文本匹配会误伤。 func TestNoDirectTerminalWrites(t *testing.T) { // 工具调用可达的包(能跑在 TUI 里的路径)。列出当前与规划中的框架包; // 尚未迁入的包 ReadDir 失败自动跳过。 pkgs := []string{ "tools", "agent", "cdp", "mcp", "session", "llm", "execx", "jsonl", "textx", "scratch", } // 允许清单:file → 理由。目前为空——确有必要时再加,并写清为什么。 allowed := map[string]string{} fset := token.NewFileSet() var offenders []string for _, pkg := range pkgs { dir := filepath.Join("..", pkg) entries, err := os.ReadDir(dir) if err != nil { continue } for _, e := range entries { name := e.Name() if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { continue } path := filepath.Join(dir, name) if _, ok := allowed[path]; ok { continue } f, err := parser.ParseFile(fset, path, nil, 0) if err != nil { t.Fatalf("解析 %s 失败: %v", path, err) } ast.Inspect(f, func(n ast.Node) bool { switch v := n.(type) { case *ast.SelectorExpr: // os.Stdout / os.Stderr if id, ok := v.X.(*ast.Ident); ok && id.Name == "os" && (v.Sel.Name == "Stdout" || v.Sel.Name == "Stderr") { pos := fset.Position(v.Pos()) offenders = append(offenders, pos.String()+" 直写 os."+v.Sel.Name) } // fmt.Print / Println / Printf(默认写 stdout) if id, ok := v.X.(*ast.Ident); ok && id.Name == "fmt" { switch v.Sel.Name { case "Print", "Println", "Printf": pos := fset.Position(v.Pos()) offenders = append(offenders, pos.String()+" fmt."+v.Sel.Name+" 写 stdout") } } } return true }) } } if len(offenders) > 0 { t.Errorf("工具可达的包里有 %d 处直接写终端——TUI 的 alt-screen 会被打穿。\n"+ "诊断请走 internal/diag(Printf/Println),子进程 stderr 用 diag.Indirect():\n %s", len(offenders), strings.Join(offenders, "\n ")) } }