Files
homeagent-sdk/tools/annotate_parallel/main.go
JianFeeeee deeda22650 tools: 工具并发安全审计器(audit_parallel + annotate_parallel)
两个工具,都是为了让「这个工具能不能并发」有**可复现的依据**,而不是靠人眼扫。

## tools/audit_parallel.go —— 审计

为什么需要它:正则扫 `ToolDef` 字面量**不可靠**。用它审计主仓 27 个工具时,
把 `config_set` 判成"无共享写",而它的 handler 其实在 `p.handleSet` 里且无锁 ——
原因:RegisterTool 第三个参数是方法名/闭包,正则看不到执行体。

本工具用 go/ast 从**注册点跟进到 handler 实现**,递归 3 层(带环检测),
输出三态:SAFE / SERIAL / UNKNOWN。
★ UNKNOWN 一律不声明并发安全 —— 追不到实现就不能声称安全。

### 开发它时踩的坑(都写在代码注释里)

- **通用 HTTP 包装器**:vanblog 的 manage_social / manage_settings 全走
  `p.do("GET"|"POST"|"PUT", ...)`,方法名毫无写操作迹象,纯靠名字匹配
  **全判成 SAFE** —— 而它们明确含 POST/PUT。审计工具给出与代码相反的结论,
  比不给结论更危险(人会信它)。改为按 HTTP 动词判定后,
  统计从 72/37 变为 46/63。
- **链式调用丢方法**:`p.sdk.Settings().Set(...)` 是三段链,只看最外层只得到
  "Settings",`.Set` 整个丢失 ⇒ handleConfigure(写配置 + 启停服务)被判只读。
  改为收集整条链的所有方法名。
- **变量名 ≠ 类型名**:调用点写 `p.handleRead()`,定义处是
  `func (p *Plugin) handleRead()`。拿变量名去查类型索引**永远匹配不上**,
  143 个工具全报 UNKNOWN。须建"变量名 → 接收者类型"索引。
- **方法接收者不是局部变量**:example 里根本没有 `p := &Plugin{}`,
  p 是 Start 的接收者,局部变量索引全空。须把接收者变量名也纳入索引。
- **枚举被字符串污染**:verdict = "Serial:true" 而 report 只认三个枚举值
  ⇒ 全部落进 UNKNOWN,输出"共 13:UNKNOWN 13",看着像工具没在工作。
- **作者声明必须优先**:我把"已声明"当 finding 记录后照常跑写入检测并
  **覆盖** verdict,于是 plugin_install(已标 Serial:true)被判 SAFE。

## tools/annotate_parallel/ —— 标注

按 SDK 风格插入声明项:Name 在首位,声明项在末尾(Parameters 之后、
handler 之前),不打散 gofmt 对齐。

插入点必须用**括号深度 + 记录进入深度 3 的行号**定位:
- `RegisterTool( =1, ToolDef{ =2, Parameters{ =3`
- 只判 depth==2 会在 `Name:` 行就返回(那行本来就是深度 2),
  插入点跑到 RegisterTool 之前,编译报 "expected 1 expression"
- 空 `properties: map[string]interface{}{}` 让深度**在同一行**进出平衡,
  所以"曾触及深度 3"也不能作门控,必须记行号
- 试过用正则找"最后一个顶层字段",被嵌套 map 里的同形文本骗到,
  823 处错误重排把文件改坏 —— 文本匹配改结构体字面量就是这条路

用法:go build -o /tmp/annotate ./tools/annotate_parallel
     /tmp/annotate <file> <tool:parallel|serial:说明> ...
2026-09-27 16:17:04 +08:00

173 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 命令 annotate_parallel 按 SDK 声明风格为工具加并发安全声明。
//
// 风格要求(照 SDK 的 NoMemory 走,不自创):
//
// · 声明项是**结构体字段**(ParallelSafe / Serial),不是注释标记;
// · 插在 Parameters 之后、handler 之前 —— 即字面量的**末尾**,
// 与 SDK 里 NoMemory/ContextPolicy/RecallPolicy 的位置一致;
// · Name 保持在首位,不打散 gofmt 对齐。
//
// 为什么用括号深度定位插入点:之前用正则找"最后一个顶层字段",
// 会被嵌套 map 里的同形文本骗到,结果把声明插到 Parameters 中间,
// 甚至把文件改坏(823 处重排)。深度计数是唯一可靠的。
//
// 用法:annotate_parallel <file> <tool:kind:note> ...
//
// kind: parallel | serial
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func main() {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "用法: annotate_parallel <file> <tool:kind:note>...")
os.Exit(2)
}
path := os.Args[1]
lines := readLines(path)
// 从后往前改,避免行号漂移
type job struct {
tool, kind, note string
}
var jobs []job
for _, arg := range os.Args[2:] {
p := strings.SplitN(arg, ":", 3)
if len(p) != 3 {
fmt.Fprintf(os.Stderr, "参数格式错: %q\n", arg)
os.Exit(2)
}
jobs = append(jobs, job{p[0], p[1], p[2]})
}
// 反序处理(同一文件里多个工具,位置互不影响,但保守起见从后往前)
for i := len(jobs) - 1; i >= 0; i-- {
j := jobs[i]
at, err := findInsertPoint(lines, j.tool)
if err != nil {
fmt.Fprintf(os.Stderr, " 跳过 %s: %v\n", j.tool, err)
continue
}
field := "ParallelSafe: true,"
if j.kind == "serial" {
field = "Serial: true,"
}
ins := []string{"\t\t// " + j.note, "\t\t" + field}
out := append([]string{}, lines[:at]...)
out = append(out, ins...)
out = append(out, lines[at:]...)
lines = out
fmt.Printf(" %s @line %d (%s)\n", j.tool, at+1, j.kind)
}
writeLines(path, lines)
}
// findInsertPoint 找到该 RegisterTool 字面量中,Parameters 闭合之后的位置。
func findInsertPoint(lines []string, tool string) (int, error) {
head := fmt.Sprintf(`RegisterTool("%s"`, tool)
start := -1
for i, l := range lines {
if strings.Contains(l, head) {
start = i
break
}
}
if start < 0 {
return 0, fmt.Errorf("找不到 RegisterTool(%q)", tool)
}
// 从 RegisterTool( 开始做括号深度追踪,找到 ToolDef 字面量的闭合 "}," 行
depth := 0
started := false
enteredAt := -1
inStr := false
esc := false
for i := start; i < len(lines); i++ {
for _, ch := range lines[i] {
if esc {
esc = false
continue
}
if ch == '\\' && inStr {
esc = true
continue
}
if ch == '"' {
inStr = !inStr
continue
}
if inStr {
continue
}
switch ch {
case '(', '{', '[':
depth++
started = true
case ')', '}', ']':
depth--
}
}
// 插入点 = Parameters 字段的**闭合之后**。
//
// 精确定位法:Parameters 起始处的深度是 3(RegisterTool( → ToolDef{ →
// Parameters{);它的闭合就是深度**首次从 3 回到 2** 的那一行。
//
// ⚠️ 我第一版没有这样做,而是"找 required 行的下一行,没有就返回字面量
// 闭合行"。对于没有 required 的工具(如 config_list_plugins),
// 后者落在 properties{} 内部 —— 生成的代码是
// "properties": map[string]interface{}{},
// ParallelSafe: true, ← 跑到 map 里去了
// 编译报 undefined: ParallelSafe,症状离原因很远。
// 插入点 = Parameters 字段闭合的**下一行**。
//
// 深度:RegisterTool( =1, ToolDef{ =2, Parameters{ =3。
//
// ★ 两个坑都是"同一行内深度进出平衡"造成的:
//
// 1. 空 properties:`"properties": map[string]interface{}{},`
// 深度 3→2 在**同一行**完成。所以不能用"曾触及 3"作门控,
// 必须记住**行号**:进入 3 的那行之后,首个回到 2 的行才是闭合行。
//
// 2. Name:/Description: 本来就在深度 2,早于 Parameters。
// 只判 depth==2 会在 Name 行就返回,插入点跑到 RegisterTool 之前,
// 编译报 "expected 1 expression"。
if started && depth >= 3 {
enteredAt = i
}
if enteredAt >= 0 && i > enteredAt && depth <= 2 {
return i + 1, nil
}
}
return 0, fmt.Errorf("括号深度追踪未收敛")
}
func readLines(p string) []string {
f, err := os.Open(p)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer f.Close()
var out []string
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
out = append(out, sc.Text())
}
return out
}
func writeLines(p string, lines []string) {
var sb strings.Builder
for _, l := range lines {
sb.WriteString(l)
sb.WriteString("\n")
}
if err := os.WriteFile(p, []byte(sb.String()), 0644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}