// scan.go - core of the dead-field scanner. // // Finds exported fields on exported structs that are defined but never // read inside the scanned module. Sibling cases of engine.Config.MCPServers // (fixed 57a06c6) and engine.Config.Plugins (fixed 07fe345) motivated the // tool: a field declared on an SDK-facing struct but never consumed by the // owning code is silently dead to consumers. // // Design choices: // - types-aware, not grep-based. A selector like `.MCPServers` appears // on engine.Config, plugin.Manifest, and several other structs; only // a go/types-driven identifier resolution can say which one the caller // meant. See queued-task note. // - write-context classification via inspector.WithStack rather than // guessing from name shape. Writes (AssignStmt LHS, IncDecStmt, // CompositeLit key) do NOT count as reads -- a field only ever // assigned to, never read back, is exactly the dead-field pattern. // - scope limited to the loaded module. Cross-module consumers (e.g. // the flysafe app) are invisible to this scanner by design: a field // only read by an external module is still a dead field from the // library's perspective, and that is the pattern we want to catch. // // scan.go - dead-field scanner 的核心. // // 找 exported struct 上声明但扫描模块内从未被读过的 exported field. // 动机是 engine.Config.MCPServers (57a06c6 已修) 和 engine.Config.Plugins // (07fe345 已修) 这类案例: SDK-facing struct 上声明了字段但 owning code // 从不读, 对消费方静默死字段. // // 设计选择: // - 走 go/types, 不走 grep. `.MCPServers` 这种 selector 在 engine.Config, // plugin.Manifest 和其他几个 struct 都出现过, 只有 types 驱动的 // identifier resolution 能说清调用方指哪个. 见 queued task 笔记. // - write-context 分类走 inspector.WithStack, 不靠名字形状. 写 // (AssignStmt LHS / IncDecStmt / CompositeLit key) 不算 read -- 只被 // 赋值从未被读的字段恰恰就是死字段模式. // - scope 限于加载模块自身. 跨模块消费方 (e.g. flysafe app) 对本扫描器 // 不可见, 这是刻意的: 只被外部模块读的字段从库的角度看仍然是死字段, // 恰好就是要抓的模式. package main import ( "fmt" "go/ast" "go/token" "go/types" "sort" "golang.org/x/tools/go/ast/inspector" "golang.org/x/tools/go/packages" ) // FieldInfo is the scan output row for one dead exported field. // Tag captures the full struct tag (e.g. `json:"foo,omitempty" xml:"foo"`) // so downstream triage can distinguish reflection-read fields (json-tagged) // from never-consumed fields without re-parsing source. // TypeStr is the Go type shown at the declaration site, aimed at giving // an LLM triage pass enough context to classify without reading source. // // FieldInfo 是一条死字段扫描输出. Tag 存完整的 struct tag // (e.g. `json:"foo,omitempty" xml:"foo"`), 下游 triage 据此区分反射读 // 字段 (带 json tag) 和纯无人读字段, 不必重新解析源码. TypeStr 是声明处 // 的 Go 类型, 让后续 LLM triage 有足够上下文做分类, 免于重读源文件. type FieldInfo struct { Pkg string Struct string Field string Tag string TypeStr string Pos token.Position } // Scanner loads packages and classifies exported struct fields. // // Scanner 加载包并分类 exported struct field. type Scanner struct { // Dir is the working directory for packages.Load (typically core/). // Dir 是 packages.Load 的工作目录 (通常指 core/). Dir string // Patterns is the Go package pattern list, e.g. []string{"./pkg/..."}. // Patterns 是 Go 包模式列表, e.g. []string{"./pkg/..."}. Patterns []string } // Scan returns the sorted list of exported fields that the scanned module // never reads. Packages that fail to load propagate as an error; partial // loads are not silently swallowed because a missing AST would under-count // reads and produce false positives. // // Scan 返回扫描模块内从未被读的 exported field 清单 (已排序). 加载失败的 // 包作为 error 抛出; 部分加载不会静默吞掉, 因为缺失 AST 会漏计读, 产生 // 假阳性. func (s *Scanner) Scan() ([]FieldInfo, error) { cfg := &packages.Config{ Mode: packages.NeedName | packages.NeedTypes | packages.NeedSyntax | packages.NeedTypesInfo | packages.NeedDeps | packages.NeedImports | packages.NeedFiles, Dir: s.Dir, } pkgs, err := packages.Load(cfg, s.Patterns...) if err != nil { return nil, fmt.Errorf("packages.Load: %w", err) } if n := packages.PrintErrors(pkgs); n > 0 { return nil, fmt.Errorf("%d package load error(s); see stderr", n) } // Pass 1: collect exported fields of exported named structs. // Pass 1: 收集 exported 命名 struct 的 exported field. fields := map[*types.Var]FieldInfo{} for _, p := range pkgs { collectFields(p, fields) } // Pass 2: walk AST, mark each field use as read or write, record reads. // Pass 2: 遍历 AST, 为每个字段 use 分类 read/write, 记录 read. reads := map[*types.Var]bool{} for _, p := range pkgs { markReads(p, fields, reads) } // Pass 2b: reflection-consumption pass. encoding/json reads all exported // fields of a struct it marshals, but the reads happen via reflect — // there is no SelectorExpr the AST walker in Pass 2 can see. A struct // that's only consumed by json.Marshal / Encoder.Encode would otherwise // look entirely dead. We walk CallExpr looking for calls into // encoding/json serialization funcs and mark the exported fields of the // argument's (possibly nested / sliced / pointed-to) struct type as // live. Decoders (Unmarshal / Decode) are NOT marked — they write the // struct, which is not a read-side consumer. // // Pass 2b: 反射消费 pass. encoding/json marshal struct 时通过 reflect // 读所有 exported field, AST 层看不到对应 SelectorExpr. 仅经 json.Marshal // / Encoder.Encode 消费的 struct 否则会整体显死. 本 pass 扫 CallExpr 找 // encoding/json 序列化函数, 把参数 (可能嵌套 / slice / pointer) struct // 类型的 exported field 标 live. Decoder (Unmarshal / Decode) 不 mark — // 它们是写入 struct, 不属于读端消费. for _, p := range pkgs { markJSONMarshalReads(p, fields, reads) } // Pass 3: dead = fields - reads. // Pass 3: dead = fields - reads. var dead []FieldInfo for v, info := range fields { if !reads[v] { dead = append(dead, info) } } sort.Slice(dead, func(i, j int) bool { if dead[i].Pkg != dead[j].Pkg { return dead[i].Pkg < dead[j].Pkg } if dead[i].Struct != dead[j].Struct { return dead[i].Struct < dead[j].Struct } return dead[i].Field < dead[j].Field }) return dead, nil } func collectFields(p *packages.Package, out map[*types.Var]FieldInfo) { scope := p.Types.Scope() for _, name := range scope.Names() { tn, ok := scope.Lookup(name).(*types.TypeName) if !ok || !tn.Exported() { continue } // Underlying() unwraps aliases but also flattens to the struct // type. Unnamed or non-struct types are skipped. // // Underlying() 剥 alias 并平坦到 struct 类型. 非 struct 或匿名 // 类型跳过. st, ok := tn.Type().Underlying().(*types.Struct) if !ok { continue } for i := 0; i < st.NumFields(); i++ { f := st.Field(i) if !f.Exported() { continue } // Skip embedded fields: they are accessed via the embedding // struct's selector chain and have their own read-tracking // as the named type -- treating them as a field would // double-count or miss uses depending on the access form. // // 跳过 embedded field: 它们通过 embedding struct 的 selector // 链被访问, read-tracking 走命名类型自己; 当成字段处理会 // 根据访问形式双计或漏计. if f.Embedded() { continue } out[f] = FieldInfo{ Pkg: p.PkgPath, Struct: tn.Name(), Field: f.Name(), Tag: st.Tag(i), TypeStr: f.Type().String(), Pos: p.Fset.Position(f.Pos()), } } } } func markReads(p *packages.Package, fields map[*types.Var]FieldInfo, reads map[*types.Var]bool) { ins := inspector.New(p.Syntax) // Single-pass WithStack lets us inspect the parent chain for each // SelectorExpr to classify read vs write without a second traversal. // // 单次 WithStack 就能对每个 SelectorExpr 借父链分类 read/write, // 无需二次遍历. ins.WithStack(nil, func(n ast.Node, push bool, stack []ast.Node) bool { if !push { return false } sel, ok := n.(*ast.SelectorExpr) if !ok { return true } v, ok := p.TypesInfo.ObjectOf(sel.Sel).(*types.Var) if !ok { return true } if _, tracked := fields[v]; !tracked { return true } if isWriteContext(stack) { return true } reads[v] = true return true }) } // isWriteContext classifies whether the current SelectorExpr node at the // top of the stack is a pure write (no read semantics). Op-assigns like // "+=" and address-taken "&x.f" are treated as reads by falling through // the default branch -- they either read-then-write or may be read via // the pointer, so marking them as reads avoids false positives. // // isWriteContext 判定当前位于 stack 顶部的 SelectorExpr 是否是纯写 (无 // 读语义). 复合赋值 "+=" 和取地址 "&x.f" 走 default 分支被算 read -- // 它们要么读-再-写, 要么可能通过指针读, 算 read 避免假阳性. func isWriteContext(stack []ast.Node) bool { if len(stack) < 2 { return false } current := stack[len(stack)-1] parent := stack[len(stack)-2] switch p := parent.(type) { case *ast.AssignStmt: // Only plain `=` / `:=` are pure writes. `+=` / `-=` etc. read // the LHS value first. // // 只有纯 `=` / `:=` 是纯写. `+=` / `-=` 等会先读 LHS 值. if p.Tok != token.ASSIGN && p.Tok != token.DEFINE { return false } for _, lhs := range p.Lhs { if lhs == current { return true } } case *ast.IncDecStmt: // x.f++ reads the old value too; classify as read to avoid // false positives. Intentional fall-through. // // x.f++ 会先读旧值; 算 read 避免假阳性. 有意走 default. _ = p } return false } // markJSONMarshalReads scans CallExpr nodes for encoding/json serialization // entry points (Marshal / MarshalIndent, Encoder.Encode) and marks the // argument's reachable exported struct fields as live. Pointers / slices / // arrays / maps are unwrapped to their element type; nested structs are // walked recursively with a seen-set to stop on type cycles. // // markJSONMarshalReads 扫 CallExpr 找 encoding/json 序列化入口 // (Marshal / MarshalIndent, Encoder.Encode), 把参数可达的 exported struct // field 标 live. Pointer / slice / array / map 解到元素类型; 嵌套 struct // 递归走, 用 seen-set 防止类型循环. func markJSONMarshalReads(p *packages.Package, fields map[*types.Var]FieldInfo, reads map[*types.Var]bool) { ins := inspector.New(p.Syntax) ins.Preorder([]ast.Node{(*ast.CallExpr)(nil)}, func(n ast.Node) { call := n.(*ast.CallExpr) if !isJSONMarshalCall(p.TypesInfo, call) { return } // For Marshal / MarshalIndent the payload is Args[0]; for // Encoder.Encode it is also Args[0] (the receiver carries the // writer, not the value). // // Marshal / MarshalIndent 的 payload 是 Args[0]; Encoder.Encode // 的 payload 也是 Args[0] (receiver 持 writer, 不是值). if len(call.Args) == 0 { return } argType := p.TypesInfo.TypeOf(call.Args[0]) if argType == nil { return } markStructFieldsLive(argType, fields, reads, make(map[types.Type]bool)) }) } // isJSONMarshalCall reports whether the given CallExpr targets an // encoding/json read-side serialization function. Matches: // // json.Marshal(v) / json.MarshalIndent(v, ...) // json.NewEncoder(w).Encode(v) // // Does NOT match Unmarshal / Decode (those write into v; their fields are // already counted at the read sites of the consumer). // // isJSONMarshalCall 判定 CallExpr 是否调用 encoding/json 的读端序列化函 // 数. 匹配: json.Marshal / json.MarshalIndent / (*json.Encoder).Encode. // 不匹配 Unmarshal / Decode (它们写入 v; 那些字段由消费者读点自然计 read). func isJSONMarshalCall(info *types.Info, call *ast.CallExpr) bool { sel, ok := call.Fun.(*ast.SelectorExpr) if !ok { return false } fn, ok := info.ObjectOf(sel.Sel).(*types.Func) if !ok { return false } pkg := fn.Pkg() if pkg == nil || pkg.Path() != "encoding/json" { return false } switch fn.Name() { case "Marshal", "MarshalIndent", "Encode": return true } return false } // markStructFieldsLive walks t's structural type, unwrapping pointers / // slices / arrays / maps, and marks each exported field of any struct it // reaches as a live read. Recurses into field types so nested structs // (the common case with composed types like planCmdResponse.Plan // *QueuedPlan) are also marked. // // markStructFieldsLive 走 t 的结构类型, 解 pointer / slice / array / map, // 把遇到的任何 struct 的 exported field 都标 live. 递归到字段类型以覆盖 // 嵌套 struct (planCmdResponse.Plan *QueuedPlan 这类组合类型). func markStructFieldsLive(t types.Type, fields map[*types.Var]FieldInfo, reads map[*types.Var]bool, seen map[types.Type]bool) { if t == nil || seen[t] { return } seen[t] = true // Unwrap pointer / slice / array / map to reach the struct-carrying // element type. Map keys are also walked for completeness (rare in // practice but preserves "everything reachable gets read" semantics). // // 解 pointer / slice / array / map 到承载 struct 的元素类型. Map key // 也走一遍 (实际罕见, 但保持 "可达即读" 语义). for { switch u := t.(type) { case *types.Pointer: t = u.Elem() continue case *types.Slice: t = u.Elem() continue case *types.Array: t = u.Elem() continue case *types.Map: markStructFieldsLive(u.Key(), fields, reads, seen) t = u.Elem() continue } break } st, ok := t.Underlying().(*types.Struct) if !ok { return } for i := 0; i < st.NumFields(); i++ { f := st.Field(i) if !f.Exported() { continue } if _, tracked := fields[f]; tracked { reads[f] = true } // Recurse into the field's type so nested / composed structs // also get their fields marked. // // 递归到字段类型, 让嵌套 / 组合 struct 的字段也被标. markStructFieldsLive(f.Type(), fields, reads, seen) } }