From bd4563ff7d50ec9e1dd59469042ff0f1e0f788f0 Mon Sep 17 00:00:00 2001 From: zhaoyihang Date: Fri, 21 Mar 2025 09:17:00 +0800 Subject: [PATCH 1/3] chore: update version --- cmd/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/version.go b/cmd/version.go index 43626b2..74e9774 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" ) -const Version = "0.1.3" +const Version = "0.1.4" var versionCmd = &cobra.Command{ Use: "version", From f488bce453bb0ca649d5d93860280cf10266981e Mon Sep 17 00:00:00 2001 From: zhaoyihang Date: Mon, 24 Mar 2025 16:23:48 +0800 Subject: [PATCH 2/3] feat: impl top declear dependency analysis --- cmd/dependency.go | 43 +++ cmd/report.go | 2 +- cmd/veronica.go | 1 + cmd/version.go | 2 +- go.mod | 7 +- go.sum | 6 + parser/dependency.go | 622 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 679 insertions(+), 4 deletions(-) create mode 100644 cmd/dependency.go create mode 100644 parser/dependency.go diff --git a/cmd/dependency.go b/cmd/dependency.go new file mode 100644 index 0000000..452881d --- /dev/null +++ b/cmd/dependency.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "fmt" + "log" + "os" + + "github.com/bootun/veronica/parser" + "github.com/spf13/cobra" +) + +var dependencyCmd = &cobra.Command{ + Use: "dependency", + Short: "dependency", + Run: func(cmd *cobra.Command, args []string) { + if targetID == "" { + cmd.Usage() + os.Exit(1) + } + + dependencyInfo, err := parser.BuildDependency(repo) + if err != nil { + log.Fatalf("build dependency: %s", err) + } + deps, err := dependencyInfo.GetDependency(targetID) + if err != nil { + log.Fatalf("get dependency: %s", err) + } + fmt.Printf("target: %s\n", targetID) + for _, dep := range deps { + fmt.Println(dep) + } + }, +} + +var ( + targetID string +) + +func init() { + dependencyCmd.Flags().StringVarP(&targetID, "target", "t", "", "target") + dependencyCmd.Flags().StringVarP(&repo, "repo", "r", ".", "repo path") +} diff --git a/cmd/report.go b/cmd/report.go index c7e89a4..1736e0f 100644 --- a/cmd/report.go +++ b/cmd/report.go @@ -57,7 +57,7 @@ var ( outputFormat string oldCommit string newCommit string - repo string + repo string // 仓库路径 ) const ( diff --git a/cmd/veronica.go b/cmd/veronica.go index 1c456af..34e8463 100644 --- a/cmd/veronica.go +++ b/cmd/veronica.go @@ -11,6 +11,7 @@ var rootCmd = &cobra.Command{ func init() { rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(reportCmd) + rootCmd.AddCommand(dependencyCmd) } func Execute() error { diff --git a/cmd/version.go b/cmd/version.go index 74e9774..d360d07 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" ) -const Version = "0.1.4" +const Version = "0.1.5" var versionCmd = &cobra.Command{ Use: "version", diff --git a/go.mod b/go.mod index e244686..0b2634e 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,18 @@ module github.com/bootun/veronica -go 1.17 +go 1.20 require ( github.com/bmatcuk/doublestar/v4 v4.2.0 github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.8.0 + golang.org/x/tools v0.21.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect -) \ No newline at end of file + golang.org/x/mod v0.17.0 // indirect + golang.org/x/sync v0.8.0 // indirect +) diff --git a/go.sum b/go.sum index 1fd5d0f..d6e3290 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,12 @@ github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw= +golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/parser/dependency.go b/parser/dependency.go new file mode 100644 index 0000000..456885b --- /dev/null +++ b/parser/dependency.go @@ -0,0 +1,622 @@ +package parser + +import ( + "fmt" + "go/ast" + "go/token" + "go/types" + "path/filepath" + "strings" + + "golang.org/x/tools/go/packages" +) + +// node 表示一个顶级声明节点,使用"文件:标识符"作为唯一标识。 +type node struct { + Pos token.Pos + File string // 文件名(仅基础名) + Name string // 标识符名称 + Obj types.Object +} + +// interfaceInfo 存储接口相关信息 +type interfaceInfo struct { + // key: 方法名称, value: 方法签名 + Methods map[string]*types.Func +} + +// interfaceImplementations 存储接口和实现类型的映射关系 +type interfaceImplementations struct { + // 接口ID -> 实现该接口的类型ID列表 + ImplementersMap map[string][]string + // 接口ID -> 接口方法名称 -> 实现该方法的类型ID列表 + MethodImplementersMap map[string]map[string][]string +} + +// Graph 存储节点之间的依赖关系,边表示"当前节点依赖于另一个节点" +type Graph map[string]map[string]struct{} + +// GetObjectID 获取完整标识符路径,格式:包名/文件名:标识符 +// pkg应为包括go module name的完整包名,例如:github.com/bootun/veronica/parser +func GetObjectID(pkg string, fileName string, obj string) string { + if pkg == "" { + panic("pkg is empty") + } + if fileName == "" { + panic("fileName is empty") + } + if obj == "" { + panic("obj is empty") + } + return fmt.Sprintf("%s/%s:%s", pkg, fileName, obj) +} + +type DependencyInfo struct { + // 项目内所有的顶级声明, key: NodeID, value: Node + nodes map[string]*node + // 依赖图的反向图, key: 依赖的节点ID, value: 集合(set)内存放被依赖的节点ID + revGraph Graph +} + +// GetDependency 获取 targetID 的依赖节点 +func (d *DependencyInfo) GetDependency(targetID string) ([]string, error) { + if _, ok := d.nodes[targetID]; !ok { + return nil, fmt.Errorf("target %s is not defined in project", targetID) + } + + // 利用深度优先搜索(DFS)查找所有直接或间接依赖 targetID 的节点 + visited := make(map[string]struct{}) + var dfs func(string) + dfs = func(node string) { + for dep, _ := range d.revGraph[node] { + if _, ok := visited[dep]; !ok { + visited[dep] = struct{}{} + dfs(dep) + } + } + } + dfs(targetID) + + if len(visited) == 0 { + return []string{}, nil + } + + deps := make([]string, 0, len(visited)) + for id := range visited { + deps = append(deps, id) + } + return deps, nil +} + +// BuildDependency 构建依赖关系图 +func BuildDependency(repoRoot string) (*DependencyInfo, error) { + // 加载包信息 + cfg := &packages.Config{ + Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo, + Dir: repoRoot, + } + pkgs, err := packages.Load(cfg, "./...") + if err != nil { + return nil, fmt.Errorf("parser project AST failed in %s: %v", repoRoot, err) + } + + // nodesMap:key: 对象, value: 节点唯一标识 + // 项目内所有的顶级声明 + nodesMap := make(map[types.Object]string) + // nodesInfo:存储每个节点的信息 + // key: NodeID, value: Node + nodesInfo := make(map[string]*node) + + // 所有接口信息 + // key: 接口的fullName表示, value: 接口信息 + interfacesInfo := make(map[string]*interfaceInfo) + + // 接口与实现类型的映射关系 + interfaceImpls := &interfaceImplementations{ + ImplementersMap: make(map[string][]string), + MethodImplementersMap: make(map[string]map[string][]string), + } + + // 依赖图: key->当前节点ID, value->集合(set)内存放依赖的节点ID + graph := make(Graph) + + // 遍历所有包和文件,提取顶级声明,构建接口表 + for _, pkg := range pkgs { + fset := pkg.Fset + for _, file := range pkg.Syntax { + fullFilename := fset.File(file.Pos()).Name() // 文件的绝对路径 + baseFilename := filepath.Base(fullFilename) // 文件名 + // 遍历文件中的所有顶级声明 + for _, decl := range file.Decls { + switch d := decl.(type) { + case *ast.FuncDecl: + // 函数或者方法 + if d.Name == nil { + continue + } + funcName := GetFuncOrMethodName(d) + // 节点唯一标识 + id := GetObjectID(pkg.ID, baseFilename, funcName) + obj := pkg.TypesInfo.Defs[d.Name] + if obj == nil { + continue + } + nodesMap[obj] = id + nodesInfo[id] = &node{ + Pos: d.Pos(), + File: baseFilename, + Name: funcName, + Obj: obj, + } + // 初始化依赖图节点 + if _, ok := graph[id]; !ok { + graph[id] = make(map[string]struct{}) + } + case *ast.GenDecl: + // 变量、常量、类型定义等 + for _, spec := range d.Specs { + switch s := spec.(type) { + case *ast.ValueSpec: + // 可能是变量或常量定义,可能有多个名字 + for _, ident := range s.Names { + if ident == nil { + continue + } + id := GetObjectID(pkg.ID, baseFilename, ident.Name) + obj := pkg.TypesInfo.Defs[ident] + if obj == nil { + continue + } + nodesMap[obj] = id + nodesInfo[id] = &node{ + Pos: ident.Pos(), + File: baseFilename, + Name: ident.Name, + Obj: obj, + } + if _, ok := graph[id]; !ok { + graph[id] = make(map[string]struct{}) + } + } + case *ast.TypeSpec: + // 类型定义 + if s.Name == nil { + continue + } + id := GetObjectID(pkg.ID, baseFilename, s.Name.Name) + obj := pkg.TypesInfo.Defs[s.Name] + if obj == nil { + continue + } + nodesMap[obj] = id + nodesInfo[id] = &node{ + Pos: s.Pos(), + File: baseFilename, + Name: s.Name.Name, + Obj: obj, + } + if _, ok := graph[id]; !ok { + graph[id] = make(map[string]struct{}) + } + + // 检查是否为接口定义 + if t, ok := s.Type.(*ast.InterfaceType); ok { + // 记录接口信息 + iface := &interfaceInfo{ + Methods: make(map[string]*types.Func), + } + + // 提取接口方法 + if t.Methods != nil { + for _, method := range t.Methods.List { + for _, name := range method.Names { + // 获取方法对象及其签名 + methodObj := pkg.TypesInfo.Defs[name] + if methodObj == nil { + continue + } + + methodFunc, ok := methodObj.(*types.Func) + if !ok { + continue + } + + iface.Methods[name.Name] = methodFunc + } + } + } + + // 排除空接口 (interface{}) + if len(iface.Methods) == 0 { + continue + } + + interfacesInfo[id] = iface + + // 初始化接口方法实现映射 + interfaceImpls.MethodImplementersMap[id] = make(map[string][]string) + for methodName := range iface.Methods { + interfaceImpls.MethodImplementersMap[id][methodName] = []string{} + } + } + } + } + } + } + } + } + + + // key: 类型ID, value: 方法名 -> 节点ID + typeMethodsMap := make(map[string]map[string]string) + + // 提取所有类型的方法 + for nodeID, node := range nodesInfo { + // 检查是否为方法声明(形如 (Type).Method 的名称) + if strings.HasPrefix(node.Name, "(") && strings.Contains(node.Name, ").") { + // 解析接收器类型和方法名 + parts := strings.SplitN(node.Name, ").", 2) + if len(parts) != 2 { + continue + } + + // 获取接收器类型名称(去掉括号和星号等) + recvType := strings.TrimPrefix(parts[0], "(") + recvType = strings.TrimPrefix(recvType, "*") // 处理指针接收器 + + // 获取方法名 + methodName := parts[1] + + // 构造类型的完整ID + typeFile := node.File + pkgName := strings.TrimSuffix(nodeID, "/"+typeFile+":"+node.Name) + typeID := GetObjectID(pkgName, typeFile, recvType) // 当前receiver的唯一标识 + + if _, ok := typeMethodsMap[typeID]; !ok { + typeMethodsMap[typeID] = make(map[string]string) + } + typeMethodsMap[typeID][methodName] = nodeID + } + } + + // 遍历所有顶层声明,组装接口表 + for nodeID, node := range nodesInfo { + if node.Obj == nil { + continue + } + + // 只处理类型声明 + typeNameObj, ok := node.Obj.(*types.TypeName) + if !ok { + continue + } + + typeObj := typeNameObj.Type() + if typeObj == nil { + continue + } + + // 确保是命名类型 + if _, ok := typeObj.(*types.Named); !ok { + continue + } + + // 判断这个类型是否实现了任何接口 + for ifaceID, ifaceInfo := range interfacesInfo { + // 跳过自身 + if ifaceID == nodeID { + continue + } + + // 记录实现了接口的哪些方法 + implemented := make(map[string]bool) + + // 检查这个类型是否实现了接口的所有方法 + allImplemented := true + for methodName, ifaceMethod := range ifaceInfo.Methods { + // 检查类型是否有这个方法 + methodFound := false + + // 获取接口方法签名 + ifaceMethodSig, ok := ifaceMethod.Type().(*types.Signature) + if !ok { + continue + } + + // 查找类型的方法集合中是否包含此方法 + if methods, ok := typeMethodsMap[nodeID]; ok { + if methodID, found := methods[methodName]; found { + // 获取类型方法对象 + methodNode := nodesInfo[methodID] + if methodNode == nil || methodNode.Obj == nil { + continue + } + + typeMethod, ok := methodNode.Obj.(*types.Func) + if !ok { + continue + } + + // 获取类型方法签名 + typeMethodSig, ok := typeMethod.Type().(*types.Signature) + if !ok { + continue + } + + // 检查方法签名是否匹配 + if signaturesCompatible(ifaceMethodSig, typeMethodSig) { + methodFound = true + // 记录该类型实现了接口的这个方法 + interfaceImpls.MethodImplementersMap[ifaceID][methodName] = append( + interfaceImpls.MethodImplementersMap[ifaceID][methodName], + methodID, + ) + implemented[methodName] = true + } + } + } + + if !methodFound { + // 如果缺少任何方法或签名不匹配,则不完全实现接口 + allImplemented = false + break + } + } + + // 如果实现了所有方法,则记录为接口的实现者 + if allImplemented && len(implemented) == len(ifaceInfo.Methods) { + interfaceImpls.ImplementersMap[ifaceID] = append(interfaceImpls.ImplementersMap[ifaceID], nodeID) + } + } + } + // 辅助函数:处理一个AST节点(函数体或变量初始化表达式)来查找依赖的顶级对象 + collectDependencies := func(n ast.Node, curNodeID string, pkg *packages.Package) { + ast.Inspect(n, func(n ast.Node) bool { + // 处理选择器表达式(如 a.b 形式的调用) + if sel, ok := n.(*ast.SelectorExpr); ok { + var obj types.Object + var objType types.Type + + // 处理嵌套选择器表达式 (如 a.b.c) + switch x := sel.X.(type) { + case *ast.Ident: + // 简单情况: a.b + obj = pkg.TypesInfo.Uses[x] + if obj == nil { + return true + } + objType = obj.Type() + case *ast.SelectorExpr: + // 嵌套情况: a.b.c + // 获取 a.b 的类型信息 + exprType := pkg.TypesInfo.Types[x] + if !exprType.IsValue() { + return true + } + objType = exprType.Type + default: + // 其他复杂表达式,获取表达式类型 + exprType := pkg.TypesInfo.Types[sel.X] + if !exprType.IsValue() { + return true + } + objType = exprType.Type + } + + if objType == nil { + return true + } + + // 查找右侧方法调用 + methodName := sel.Sel.Name + // 检查对象类型是否是接口类型 + _, isIface := objType.Underlying().(*types.Interface) + + // 处理指针类型的情况 + if !isIface { + if ptr, ok := objType.(*types.Pointer); ok { + _, isIface = ptr.Elem().Underlying().(*types.Interface) + } + } + + if isIface { + // 它是接口类型,需要查找所有实现了这个接口的类型 + // 首先尝试查找精确匹配的接口(如果已经记录在 interfacesInfo 中) + foundExactMatch := false + + // 遍历已知的接口 + for ifaceID, ifaceInfo := range interfacesInfo { + // 检查这个接口是否包含调用的方法 + if _, ok := ifaceInfo.Methods[methodName]; ok { + // 找到接口对应的所有实现类型 + if impls, ok := interfaceImpls.ImplementersMap[ifaceID]; ok && len(impls) > 0 { + foundExactMatch = true + for _, implTypeID := range impls { + // 查找实现类型的对应方法 + if typeMethods, ok := typeMethodsMap[implTypeID]; ok { + if methodID, found := typeMethods[methodName]; found { + // 添加从当前节点到实现类型方法的依赖关系 + graph[curNodeID][methodID] = struct{}{} + } + } + } + } + } + } + + // 如果没有找到精确匹配,则回退到查找所有包含该方法名的接口实现 + if !foundExactMatch { + for _, methodImpls := range interfaceImpls.MethodImplementersMap { + if impls, ok := methodImpls[methodName]; ok && len(impls) > 0 { + for _, implID := range impls { + graph[curNodeID][implID] = struct{}{} + } + } + } + } + } else { + // 检查是否有任何接口包含这个方法名 + for ifaceID, ifaceInfo := range interfacesInfo { + // 检查该方法是否属于接口 + if _, ok := ifaceInfo.Methods[methodName]; ok { + // 找到所有实现该接口方法的类型 + if impls, ok := interfaceImpls.MethodImplementersMap[ifaceID][methodName]; ok { + for _, implID := range impls { + // 添加依赖关系 + graph[curNodeID][implID] = struct{}{} + } + } + } + } + } + return true + } + + ident, ok := n.(*ast.Ident) + if !ok { + return true + } + + // 获取标识符对应的对象 + obj := pkg.TypesInfo.Uses[ident] + if obj == nil { + return true + } + // 判断这个对象是否在我们的顶级声明中(只考虑同一项目内部) + if depID, ok := nodesMap[obj]; ok { + // 避免自引用 + if depID != curNodeID { + graph[curNodeID][depID] = struct{}{} + } + } + return true + }) + } + + // 第二次遍历,遍历各个顶级声明对应的初始化或函数体,建立依赖关系 + for _, pkg := range pkgs { + for _, file := range pkg.Syntax { + fullFilename := pkg.Fset.File(file.Pos()).Name() + baseFilename := filepath.Base(fullFilename) + for _, decl := range file.Decls { + switch d := decl.(type) { + case *ast.FuncDecl: + if d.Name == nil || d.Body == nil { + continue + } + funcName := GetFuncOrMethodName(d) + curID := GetObjectID(pkg.ID, baseFilename, funcName) + collectDependencies(d.Body, curID, pkg) + case *ast.GenDecl: + for _, spec := range d.Specs { + switch s := spec.(type) { + case *ast.ValueSpec: + for _, ident := range s.Names { + curID := GetObjectID(pkg.ID, baseFilename, ident.Name) + // 如果有初始化表达式,则扫描之 + for _, expr := range s.Values { + collectDependencies(expr, curID, pkg) + } + } + } + } + } + } + } + } + + // 输出接口实现的信息(用于调试) + // fmt.Println("\n接口实现关系:") + // for ifaceID, impls := range interfaceImpls.ImplementersMap { + // if len(impls) > 0 { + // fmt.Printf("接口 %s 的实现类型:\n", ifaceID) + // for _, impl := range impls { + // fmt.Printf(" - %s\n", impl) + // } + // } + // } + + // 构建反向图 + revGraph := make(Graph) + for nodeID, deps := range graph { + for dep := range deps { + if _, ok := revGraph[dep]; !ok { + revGraph[dep] = make(map[string]struct{}) + } + revGraph[dep][nodeID] = struct{}{} + } + } + + return &DependencyInfo{ + nodes: nodesInfo, + revGraph: revGraph, + }, nil +} + +// exprToString 返回表达式的字符串表示(对标识符和星号类型作简单处理) +func exprToString(expr ast.Expr) string { + switch e := expr.(type) { + case *ast.Ident: + return e.Name + case *ast.StarExpr: + return "*" + exprToString(e.X) + case *ast.IndexExpr: + return exprToString(e.X) + "[" + exprToString(e.Index) + "]" + case *ast.SelectorExpr: + return exprToString(e.X) + "." + e.Sel.Name + default: + return fmt.Sprint(e) + } +} + +// GetFuncOrMethodName 获取函数或方法的名称 +func GetFuncOrMethodName(fn *ast.FuncDecl) string { + recv := "" + if fn.Recv != nil && len(fn.Recv.List) > 0 { + recv = exprToString(fn.Recv.List[0].Type) + } + if recv == "" { + return fn.Name.Name + } + return fmt.Sprintf("(%s).%s", recv, fn.Name.Name) +} + +// signaturesCompatible 检查类型方法的签名是否与接口方法的签名兼容 +func signaturesCompatible(ifaceMethodSig, typeMethodSig *types.Signature) bool { + // 检查参数数量是否相同 + if ifaceMethodSig.Params().Len() != typeMethodSig.Params().Len() { + return false + } + + // 检查返回值数量是否相同 + if ifaceMethodSig.Results().Len() != typeMethodSig.Results().Len() { + return false + } + + // 比较参数类型(忽略接收器) + for i := 0; i < ifaceMethodSig.Params().Len(); i++ { + ifaceParam := ifaceMethodSig.Params().At(i) + typeParam := typeMethodSig.Params().At(i) + + if !types.AssignableTo(typeParam.Type(), ifaceParam.Type()) { + return false + } + } + + // 比较返回值类型 + for i := 0; i < ifaceMethodSig.Results().Len(); i++ { + ifaceResult := ifaceMethodSig.Results().At(i) + typeResult := typeMethodSig.Results().At(i) + + if !types.AssignableTo(typeResult.Type(), ifaceResult.Type()) { + return false + } + } + + // 检查可变参数特性是否一致 + if ifaceMethodSig.Variadic() != typeMethodSig.Variadic() { + return false + } + + return true +} From 399aa360fa44f94b5dd95156f61f7033160e33e8 Mon Sep 17 00:00:00 2001 From: zhaoyihang Date: Mon, 24 Mar 2025 23:46:13 +0800 Subject: [PATCH 3/3] feat: support finer grained analysis --- README.md | 105 +++----- astdiff/astdiff.go | 593 ++++++++++++++++++++++++++++++++++++++++++ cmd/dependency.go | 7 +- cmd/impact.go | 209 +++++++++++++++ cmd/report.go | 73 ------ cmd/veronica.go | 2 +- cmd/version.go | 2 +- config/config.go | 10 +- parser/dependency.go | 26 +- parser/project.go | 282 ++------------------ veronica_example.yaml | 24 +- 11 files changed, 896 insertions(+), 437 deletions(-) create mode 100644 astdiff/astdiff.go create mode 100644 cmd/impact.go delete mode 100644 cmd/report.go diff --git a/README.md b/README.md index ac2c069..46a2af7 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -Veronica +veronica === -`Veronica` 的目标是成为**Go项目**的差异化构建指导工具。试想一下,如果你的项目分为许多微服务,而这个项目是以[Monorepo](https://en.wikipedia.org/wiki/Monorepo)的形式组织的,那么每次构建时,因为无法知道修改的文件会影响哪些服务,因此必须要构建所有的服务。`Veronica` 就是为了解决这一问题而诞生的,给定一个或多个文件,`Veronica` 会帮您分析项目的依赖, 并告知您该文件可能会产生哪些影响。 +`veronica` 的是**Go项目**的改动影响分析工具。通过在`veronica.yml`中配置您感兴趣的服务,`veronica` 会帮您分析项目的依赖, 并告知您此次的改动可能会产生哪些影响。 > :construction: 本项目仍处于早期阶段, 可能会经常变动 ## 前置条件 - - Git + - 已经安装Git - 项目使用go module ## 用法 @@ -12,83 +12,56 @@ Veronica ```bash go install github.com/bootun/veronica@latest ``` -2. 在项目的根目录放置[veronica.yaml](./veronica_example.yaml)文件 +2. 在项目的根目录放置[veronica.yaml](./veronica_example.yaml)文件,配置好你关心的函数/方法/变量/常量/结构体 3. 切换至项目目录,并运行veronica: + ```bash -cd $PROJECT_DIR -veronica report . +cd ${PROJECT_DIR} +veronica impact --old=HEAD~2 --new=HEAD --scope=service ``` -**详细输出效果:** -
-
-改动了 pkg/apigateway/spec 包中的 pkg/apigateway/spec/api.swagger.json 文件,可能会影响这些包的构建:
-    - cmd/api-gateway
-改动了 pkg/apigateway/spec 包中的 pkg/apigateway/spec/static.go 文件,可能会影响这些包的构建:
-    - cmd/api-gateway
-
-改动了 pkg/pb 包中的 pkg/pb/merchant_assets.pb.go 文件,可能会影响这些包的构建:
-    - cmd/api-gateway
-    - cmd/assets-cron
-    - cmd/currency-cron
-    - cmd/iam-cron
-    - cmd/iam-manager
-    - cmd/across-cron
-    - cmd/assets-manager
-    - cmd/currency-manager
-    - cmd/system-cron
-    - cmd/system-manager
-    - cmd/across-manager
 
-改动了 pkg/pb 包中的 pkg/pb/merchant_assets.pb.gw.go 文件,可能会影响这些包的构建:
-    - cmd/api-gateway
-    - cmd/assets-cron
-    - cmd/currency-cron
-    - cmd/iam-cron
-    - cmd/iam-manager
-    - cmd/across-cron
-    - cmd/assets-manager
-    - cmd/currency-manager
-    - cmd/system-cron
-    - cmd/system-manager
-    - cmd/across-manager
+该命令会让veronica比较当前指向HEAD的commit与HEAD前两个commit这两份代码之间的差异。  
 
-改动了 pkg/service/assets 包中的 pkg/service/assets/handler_merchant_assets.go 文件,可能会影响这些包的构建:
-    - cmd/assets-manager
-
-
+假设你这两次commit里改动的代码影响到了`veronica.yaml`里其中的三个服务,执行该命令,你会得到类似这样的输出: -**简略输出效果:** -
-
-cmd/api-gateway
-cmd/across-cron
-cmd/currency-cron
-cmd/iam-manager
-cmd/system-cron
-cmd/system-manager
-cmd/across-manager
-cmd/assets-cron
-cmd/assets-manager
-cmd/currency-manager
-cmd/iam-cron
-
-
+```sh +refresh_playlet_info +refresh_playlet_tags +GRPC_BatchGetPlayletInfo +# 如果你的代码让更多的服务受到了影响,veronica会将它输出到这里 +``` ## 可配置项 -**修改报告输出格式为文本** -```shell -veronica report --format=text . +**输出源代码变更可能会产生的全部影响** + +如果你在使用`veronica impact`命令时, 没有加上`--scope`参数,或使用`--scope=all`来指定veronica输出全部的影响时, +veronica会报告改动对整个项目所有的顶层声明产生的影响,包括所有的包级别的函数/方法/结构体/变量/常量,就像下面这样: + +```sh +> veronica impact --old HEAD~2 --new HEAD --scope=all + +add (*tagRepo).GetAllTagList in github.com/bootun/some-project/infra/mysql/qimao_free/tag_repo.go, dependencies: + 1. github.com/bootun/some-project/internal/app/domain/playlet/playlet_service.go:(*playletService).producePlayletInfo + 2. github.com/bootun/some-project/internal/app/domain/playlet/playlet_service.go:(*playletService).RefreshAllPlayletInfoRds + ... +modify TagBaseEnt in github.com/bootun/some-project/internal/app/domain/tags/entity/tag_entity.go, dependencies: + 1. github.com/bootun/some-project/internal/app/consumer/update_playlet.go:(*UpdatePlayletConsumer).DealPlaylet + 2. github.com/bootun/some-project/infra/mysql/qimao_free/tag_repo.go:(*tagRepo).GetOneTagById + 3. github.com/bootun/some-project/internal/server/grpc.go:(*PlayletServer).GetPlayletTagSortList + ... + 18. github.com/bootun/some-project/infra/mysql/qimao_free/tag_repo.go:(*tagRepo).GetAllTagList +remove (*playletService).setPlayletCacheInfo in github.com/bootun/some-project/internal/app/domain/playlet/playlet_service.go, dependencies: + 1. github.com/bootun/some-project/internal/app/consumer/update_playlet.go:(*UpdatePlayletConsumer).BatchWrite + ... + 8. github.com/bootun/some-project/internal/app/consumer/update_playlet.go:(*UpdatePlayletConsumer).DealPlaylet ``` +该命令会详细告诉你对那些内容做了哪些操作(add/modify/remove), 并报告该修改产生的影响。 + -## 已实现功能 - - 解析所有文件/目录之间的依赖关系 - - 报告可能影响构建的包 ## 命名背景 `Veronica`取自钢铁侠的同名外太空支援系统,在你需要升级战甲时,只需要通知维罗妮卡,它就会将战甲的模块从外太空发送给你,重新组合后完成升级。 -## 未来规划 - - 分析项目完整的AST,将veronica的粒度控制在[源码级别](https://github.com/bootun/veronica/issues/11) ## 相关阅读 - [基于大仓库的微服务差异化构建工具](https://mp.weixin.qq.com/s/XQqDyJyh1u6jU0PmUdS0LA) \ No newline at end of file diff --git a/astdiff/astdiff.go b/astdiff/astdiff.go new file mode 100644 index 0000000..ba953d8 --- /dev/null +++ b/astdiff/astdiff.go @@ -0,0 +1,593 @@ +package astdiff + +import ( + "fmt" + "go/ast" + "go/token" + "log" + "path/filepath" + "reflect" + "strings" + + "github.com/bootun/veronica/parser" + "golang.org/x/tools/go/packages" +) + +type ChangeType string + +const ( + ChangeTypeAdded ChangeType = "added" // 新增 + ChangeTypeRemoved ChangeType = "removed" // 移除 + ChangeTypeModified ChangeType = "modified" // 修改 +) + +type Change struct { + Type ChangeType // "added", "removed", "modified" + Package string + Object string // 函数名、变量名等 + ObjectType string // "func", "var", "const", "type" + ObjectID string // 对象的唯一标识符 + File string // 文件名 +} + +type AnalysisResult struct { + Changes []Change + Objects map[string]struct { + Type string + Package string + Position token.Position + Node ast.Node + } +} + +func LoadDiff(oldPkgs, newPkgs []*packages.Package) (*AnalysisResult, error) { + // 分析两个版本 + oldResult, err := analyzeCommit(oldPkgs) + if err != nil { + log.Fatalf("Failed to analyze old commit: %v", err) + } + newResult, err := analyzeCommit(newPkgs) + if err != nil { + log.Fatalf("Failed to analyze new commit: %v", err) + } + // 比较结果并输出 + result := compareResults(oldResult, newResult) + return result, nil +} + +func analyzeCommit(pkgs []*packages.Package) (*AnalysisResult, error) { + // 分析包中的顶层定义 + result := &AnalysisResult{ + Objects: make(map[string]struct { + Type string + Package string + Position token.Position + Node ast.Node + }), + } + for _, pkg := range pkgs { + analyzePackage(pkg, result) + } + + return result, nil +} + +func analyzePackage(pkg *packages.Package, result *AnalysisResult) { + pkgName := pkg.ID + for _, file := range pkg.Syntax { + fullFileName := pkg.Fset.File(file.Pos()).Name() + baseFileName := filepath.Base(fullFileName) + + // 用于存储当前遍历的顶层函数信息 + var currentFunc *ast.FuncDecl + + ast.Inspect(file, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.FuncDecl: + // 记录当前顶层函数 + currentFunc = x + funcName := parser.GetFuncOrMethodName(x) + id := parser.GetObjectID(pkgName, baseFileName, funcName) + result.Objects[id] = struct { + Type string + Package string + Position token.Position + Node ast.Node + }{ + Type: "func", + Package: pkgName, + Position: pkg.Fset.Position(x.Pos()), + Node: x, + } + case *ast.GenDecl: + // 如果这个声明在函数内部,使用顶层函数的信息 + if currentFunc != nil { + funcName := parser.GetFuncOrMethodName(currentFunc) + id := parser.GetObjectID(pkgName, baseFileName, funcName) + + // 如果这个ID已经存在,说明我们已经记录过这个函数了 + if _, exists := result.Objects[id]; exists { + return true + } + + result.Objects[id] = struct { + Type string + Package string + Position token.Position + Node ast.Node + }{ + Type: "func", + Package: pkgName, + Position: pkg.Fset.Position(currentFunc.Pos()), + Node: currentFunc, + } + return true + } + + // 处理顶层声明 + for _, spec := range x.Specs { + switch s := spec.(type) { + case *ast.ValueSpec: + for _, name := range s.Names { + var objType string + if x.Tok == token.CONST { + objType = "const" + } else { + objType = "var" + } + id := parser.GetObjectID(pkgName, baseFileName, name.Name) + result.Objects[id] = struct { + Type string + Package string + Position token.Position + Node ast.Node + }{ + Type: objType, + Package: pkgName, + Position: pkg.Fset.Position(name.Pos()), + Node: s, + } + } + case *ast.TypeSpec: + id := parser.GetObjectID(pkgName, baseFileName, s.Name.Name) + result.Objects[id] = struct { + Type string + Package string + Position token.Position + Node ast.Node + }{ + Type: "type", + Package: pkg.PkgPath, + Position: pkg.Fset.Position(s.Pos()), + Node: s, + } + } + } + } + return true + }) + } +} + +func compareResults(old, new *AnalysisResult) *AnalysisResult { + result := &AnalysisResult{ + Objects: make(map[string]struct { + Type string + Package string + Position token.Position + Node ast.Node + }), + } + + // 检查新增和修改的对象 + for key, newObj := range new.Objects { + fullFileName := newObj.Position.Filename + baseFileName := filepath.Base(fullFileName) + parts := strings.Split(key, ":") + objName := parts[len(parts)-1] + fileName := fmt.Sprintf("%s/%s", newObj.Package, baseFileName) + if oldObj, exists := old.Objects[key]; exists { + // 检查包名和类型是否变化 + if oldObj.Package != newObj.Package || oldObj.Type != newObj.Type { + result.Changes = append(result.Changes, Change{ + Type: ChangeTypeModified, + Package: newObj.Package, + Object: objName, + ObjectType: newObj.Type, + ObjectID: key, + File: fileName, + }) + } else { + // 检查对象内容是否变化 + if !astNodesEqual(oldObj.Node, newObj.Node) { + result.Changes = append(result.Changes, Change{ + Type: ChangeTypeModified, + Package: newObj.Package, + Object: objName, + ObjectType: newObj.Type, + ObjectID: key, + File: fileName, + }) + } + } + } else { + parts := strings.Split(key, ":") + objName := parts[len(parts)-1] + result.Changes = append(result.Changes, Change{ + Type: ChangeTypeAdded, + Package: newObj.Package, + Object: objName, + ObjectType: newObj.Type, + ObjectID: key, + File: fileName, + }) + } + } + + // 检查删除的对象 + for key, oldObj := range old.Objects { + if _, exists := new.Objects[key]; !exists { + parts := strings.Split(key, ":") + objName := parts[len(parts)-1] + fullFileName := oldObj.Position.Filename + baseFileName := filepath.Base(fullFileName) + fileName := fmt.Sprintf("%s/%s", oldObj.Package, baseFileName) + result.Changes = append(result.Changes, Change{ + Type: ChangeTypeRemoved, + Package: oldObj.Package, + Object: objName, + ObjectType: oldObj.Type, + ObjectID: key, + File: fileName, + }) + } + } + + return result +} + +// astNodesEqual 比较两个AST节点是否相等 +func astNodesEqual(a, b ast.Node) bool { + // 都为 nil 则相等 + if a == nil && b == nil { + return true + } + // 只有一个为 nil,则不相等。 + if a == nil || b == nil { + return false + } + // 类型必须一致 + if reflect.TypeOf(a) != reflect.TypeOf(b) { + return false + } + switch x := a.(type) { + case *ast.File: + y := b.(*ast.File) + // 比较文件名 + if !astNodesEqual(x.Name, y.Name) { + return false + } + // 比较所有声明 + if len(x.Decls) != len(y.Decls) { + return false + } + for i := range x.Decls { + if !astNodesEqual(x.Decls[i], y.Decls[i]) { + return false + } + } + return true + case *ast.FuncDecl: + y := b.(*ast.FuncDecl) + // 比较接收者、名称、函数类型、函数体 + if !astNodesEqual(x.Recv, y.Recv) { + return false + } + if !astNodesEqual(x.Name, y.Name) { + return false + } + if !astNodesEqual(x.Type, y.Type) { + return false + } + if !astNodesEqual(x.Body, y.Body) { + return false + } + return true + case *ast.FuncType: + y := b.(*ast.FuncType) + if !astNodesEqual(x.Params, y.Params) { + return false + } + if !astNodesEqual(x.Results, y.Results) { + return false + } + return true + case *ast.FieldList: + y := b.(*ast.FieldList) + if x == nil || y == nil { + return x == y + } + if len(x.List) != len(y.List) { + return false + } + for i := range x.List { + if !astNodesEqual(x.List[i], y.List[i]) { + return false + } + } + return true + case *ast.Field: + y := b.(*ast.Field) + // 比较字段名列表 + if len(x.Names) != len(y.Names) { + return false + } + for i := range x.Names { + if !astNodesEqual(x.Names[i], y.Names[i]) { + return false + } + } + // 比较类型 + if !astNodesEqual(x.Type, y.Type) { + return false + } + return true + case *ast.Ident: + y := b.(*ast.Ident) + if x == nil || y == nil { + return x == y + } + return x.Name == y.Name + case *ast.BasicLit: + y := b.(*ast.BasicLit) + return x.Value == y.Value && x.Kind == y.Kind + case *ast.BlockStmt: + y := b.(*ast.BlockStmt) + if len(x.List) != len(y.List) { + return false + } + if len(x.List) != len(y.List) { + return false + } + for i := range x.List { + if !astNodesEqual(x.List[i], y.List[i]) { + return false + } + } + return true + case *ast.ExprStmt: + y := b.(*ast.ExprStmt) + return astNodesEqual(x.X, y.X) + case *ast.ReturnStmt: + y := b.(*ast.ReturnStmt) + if len(x.Results) != len(y.Results) { + return false + } + for i := range x.Results { + if !astNodesEqual(x.Results[i], y.Results[i]) { + return false + } + } + return true + case *ast.BinaryExpr: + y := b.(*ast.BinaryExpr) + return x.Op == y.Op && astNodesEqual(x.X, y.X) && astNodesEqual(x.Y, y.Y) + case *ast.CallExpr: + y := b.(*ast.CallExpr) + if !astNodesEqual(x.Fun, y.Fun) { + return false + } + if len(x.Args) != len(y.Args) { + return false + } + for i := range x.Args { + if !astNodesEqual(x.Args[i], y.Args[i]) { + return false + } + } + return true + case *ast.AssignStmt: + y := b.(*ast.AssignStmt) + if x.Tok != y.Tok || len(x.Lhs) != len(y.Lhs) || len(x.Rhs) != len(y.Rhs) { + return false + } + for i := range x.Lhs { + if !astNodesEqual(x.Lhs[i], y.Lhs[i]) { + return false + } + } + for i := range x.Rhs { + if !astNodesEqual(x.Rhs[i], y.Rhs[i]) { + return false + } + } + return true + case *ast.DeclStmt: + y := b.(*ast.DeclStmt) + return astNodesEqual(x.Decl, y.Decl) + case *ast.IfStmt: + y := b.(*ast.IfStmt) + return astNodesEqual(x.Init, y.Init) && + astNodesEqual(x.Cond, y.Cond) && + astNodesEqual(x.Body, y.Body) && + astNodesEqual(x.Else, y.Else) + case *ast.SelectorExpr: + y := b.(*ast.SelectorExpr) + return astNodesEqual(x.X, y.X) && astNodesEqual(x.Sel, y.Sel) + case *ast.UnaryExpr: + y := b.(*ast.UnaryExpr) + return x.Op == y.Op && astNodesEqual(x.X, y.X) + case *ast.CompositeLit: + y := b.(*ast.CompositeLit) + if !astNodesEqual(x.Type, y.Type) { + return false + } + if len(x.Elts) != len(y.Elts) { + return false + } + for i := range x.Elts { + if !astNodesEqual(x.Elts[i], y.Elts[i]) { + return false + } + } + return true + case *ast.StarExpr: + y := b.(*ast.StarExpr) + return astNodesEqual(x.X, y.X) + case *ast.ParenExpr: + y := b.(*ast.ParenExpr) + return astNodesEqual(x.X, y.X) + case *ast.IndexExpr: + y := b.(*ast.IndexExpr) + return astNodesEqual(x.X, y.X) && astNodesEqual(x.Index, y.Index) + case *ast.SliceExpr: + y := b.(*ast.SliceExpr) + return astNodesEqual(x.X, y.X) && astNodesEqual(x.Low, y.Low) && astNodesEqual(x.High, y.High) && astNodesEqual(x.Max, y.Max) + case *ast.KeyValueExpr: + y := b.(*ast.KeyValueExpr) + return astNodesEqual(x.Key, y.Key) && astNodesEqual(x.Value, y.Value) + case *ast.MapType: + y := b.(*ast.MapType) + return astNodesEqual(x.Key, y.Key) && astNodesEqual(x.Value, y.Value) + case *ast.ArrayType: + y := b.(*ast.ArrayType) + return astNodesEqual(x.Len, y.Len) && astNodesEqual(x.Elt, y.Elt) + case *ast.StructType: + y := b.(*ast.StructType) + if len(x.Fields.List) != len(y.Fields.List) { + return false + } + for i := range x.Fields.List { + if !astNodesEqual(x.Fields.List[i], y.Fields.List[i]) { + return false + } + } + return true + case *ast.InterfaceType: + y := b.(*ast.InterfaceType) + if len(x.Methods.List) != len(y.Methods.List) { + return false + } + for i := range x.Methods.List { + if !astNodesEqual(x.Methods.List[i], y.Methods.List[i]) { + return false + } + } + return true + case *ast.Ellipsis: + y := b.(*ast.Ellipsis) + return astNodesEqual(x.Elt, y.Elt) + case *ast.ChanType: + y := b.(*ast.ChanType) + return x.Dir == y.Dir && astNodesEqual(x.Value, y.Value) + case *ast.ValueSpec: + y := b.(*ast.ValueSpec) + if len(x.Names) != len(y.Names) { + return false + } + for i := range x.Names { + if !astNodesEqual(x.Names[i], y.Names[i]) { + return false + } + } + if len(x.Values) != len(y.Values) { + return false + } + for i := range x.Values { + if !astNodesEqual(x.Values[i], y.Values[i]) { + return false + } + } + return astNodesEqual(x.Type, y.Type) + case *ast.BadExpr: + y := b.(*ast.BadExpr) + return x.From == y.From + case *ast.GenDecl: + y := b.(*ast.GenDecl) + if x.Tok != y.Tok || len(x.Specs) != len(y.Specs) { + return false + } + if len(x.Specs) != len(y.Specs) { + return false + } + for i := range x.Specs { + if !astNodesEqual(x.Specs[i], y.Specs[i]) { + return false + } + } + return true + case *ast.ImportSpec: + y := b.(*ast.ImportSpec) + return x.Path.Value == y.Path.Value && x.Name != nil && y.Name != nil && x.Name.Name == y.Name.Name + case *ast.RangeStmt: + y := b.(*ast.RangeStmt) + return astNodesEqual(x.Key, y.Key) && astNodesEqual(x.Value, y.Value) && astNodesEqual(x.X, y.X) && astNodesEqual(x.Body, y.Body) + case *ast.CaseClause: + y := b.(*ast.CaseClause) + if len(x.List) != len(y.List) { + return false + } + for i := range x.List { + if !astNodesEqual(x.List[i], y.List[i]) { + return false + } + } + return true + case *ast.SwitchStmt: + y := b.(*ast.SwitchStmt) + return astNodesEqual(x.Init, y.Init) && astNodesEqual(x.Tag, y.Tag) && astNodesEqual(x.Body, y.Body) + case *ast.TypeSpec: + y := b.(*ast.TypeSpec) + return astNodesEqual(x.Name, y.Name) && astNodesEqual(x.Type, y.Type) + case *ast.TypeAssertExpr: + y := b.(*ast.TypeAssertExpr) + return astNodesEqual(x.X, y.X) && astNodesEqual(x.Type, y.Type) + case *ast.FuncLit: + y := b.(*ast.FuncLit) + return astNodesEqual(x.Type, y.Type) && astNodesEqual(x.Body, y.Body) + case *ast.DeferStmt: + y := b.(*ast.DeferStmt) + return astNodesEqual(x.Call, y.Call) + case *ast.LabeledStmt: + y := b.(*ast.LabeledStmt) + return astNodesEqual(x.Label, y.Label) && astNodesEqual(x.Stmt, y.Stmt) + case *ast.GoStmt: + y := b.(*ast.GoStmt) + return astNodesEqual(x.Call, y.Call) + case *ast.SendStmt: + y := b.(*ast.SendStmt) + return astNodesEqual(x.Chan, y.Chan) && astNodesEqual(x.Value, y.Value) + case *ast.IncDecStmt: + y := b.(*ast.IncDecStmt) + return astNodesEqual(x.X, y.X) && x.Tok == y.Tok + case *ast.BranchStmt: + y := b.(*ast.BranchStmt) + return x.Tok == y.Tok && astNodesEqual(x.Label, y.Label) + case *ast.BadStmt: + y := b.(*ast.BadStmt) + return x.From == y.From + case *ast.ForStmt: + y := b.(*ast.ForStmt) + return astNodesEqual(x.Init, y.Init) && astNodesEqual(x.Cond, y.Cond) && astNodesEqual(x.Post, y.Post) && astNodesEqual(x.Body, y.Body) + case *ast.SelectStmt: + y := b.(*ast.SelectStmt) + return astNodesEqual(x.Body, y.Body) + case *ast.CommClause: + y := b.(*ast.CommClause) + if !astNodesEqual(x.Comm, y.Comm) { + return false + } + if len(x.Body) != len(y.Body) { + return false + } + for i := range x.Body { + if !astNodesEqual(x.Body[i], y.Body[i]) { + return false + } + } + return true + + default: + panic(fmt.Sprintf("未处理的节点类型: %T, a: %v, b: %v\n", x, a, b)) + } +} diff --git a/cmd/dependency.go b/cmd/dependency.go index 452881d..311974a 100644 --- a/cmd/dependency.go +++ b/cmd/dependency.go @@ -17,8 +17,11 @@ var dependencyCmd = &cobra.Command{ cmd.Usage() os.Exit(1) } - - dependencyInfo, err := parser.BuildDependency(repo) + pkgs, err := parser.LoadPackages(repo) + if err != nil { + log.Fatalf("load packages: %s", err) + } + dependencyInfo, err := parser.BuildDependency(pkgs) if err != nil { log.Fatalf("build dependency: %s", err) } diff --git a/cmd/impact.go b/cmd/impact.go new file mode 100644 index 0000000..25cbfe3 --- /dev/null +++ b/cmd/impact.go @@ -0,0 +1,209 @@ +package cmd + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/bootun/veronica/astdiff" + "github.com/bootun/veronica/parser" + "github.com/spf13/cobra" +) + +var impactCmd = &cobra.Command{ + Use: "impact", + Short: "impact", + Run: func(cmd *cobra.Command, args []string) { + if oldCommit == "" { + cmd.Usage() + os.Exit(1) + } + if newCommit == "" { + cmd.Usage() + os.Exit(1) + } + Impact(oldCommit, newCommit) + }, +} + +var ( + outputFormat string // 输出格式(json, text) + oldCommit string + newCommit string + repo string // 仓库路径 + scope string // 报告的变更范围(all, service) +) + +const ( + OutputFormatJson = "json" + OutputFormatText = "text" + + ScopeAll = "all" + ScopeService = "service" +) + +func init() { + impactCmd.Flags().StringVarP(&oldCommit, "old", "o", "", "old commit") + impactCmd.Flags().StringVarP(&newCommit, "new", "n", "", "new commit") + impactCmd.Flags().StringVarP(&repo, "repo", "r", ".", "repo path") + impactCmd.Flags().StringVarP(&scope, "scope", "s", ScopeAll, "report scope, options: all, service") + impactCmd.Flags().StringVarP(&outputFormat, "format", "f", OutputFormatJson, "output format, options: json, text") +} + +func Impact(oldCommit, newCommit string) { + log.SetFlags(log.Lshortfile | log.LstdFlags) + project, err := parser.NewProject(repo) + if err != nil { + log.Fatalf("load project: %s", err) + } + // 创建临时目录 + tmpDir, err := os.MkdirTemp("", "veronica-astdiff-*") + if err != nil { + log.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tmpDir) + oldDir := filepath.Join(tmpDir, "old") + newDir := filepath.Join(tmpDir, "new") + // 使用 git archive 导出指定版本 + if err := exportCommit(oldCommit, oldDir); err != nil { + log.Fatalf("failed to export commit: %v", err) + } + if err := exportCommit(newCommit, newDir); err != nil { + log.Fatalf("failed to export commit: %v", err) + } + + // 加载包信息 + oldPkgs, err := parser.LoadPackages(oldDir) + if err != nil { + log.Fatalf("failed to load packages: %v", err) + } + newPkgs, err := parser.LoadPackages(newDir) + if err != nil { + log.Fatalf("failed to load packages: %v", err) + } + + // 分析AST差异 + diff, err := astdiff.LoadDiff(oldPkgs, newPkgs) + if err != nil { + log.Fatalf("failed to load diff: %v", err) + } + oldDeps, err := parser.BuildDependency(oldPkgs) + if err != nil { + log.Fatalf("failed to build dependency: %v", err) + } + newDeps, err := parser.BuildDependency(newPkgs) + if err != nil { + log.Fatalf("failed to build dependency: %v", err) + } + + switch scope { + case ScopeAll: + // 报告所有影响 + for _, change := range diff.Changes { + switch change.Type { + case astdiff.ChangeTypeAdded: + deps, err := newDeps.GetDependency(change.ObjectID) + if err != nil { + log.Fatalf("failed to get dependency: %v", err) + } + fmt.Printf("add %s in %s, dependencies:\n", change.Object, change.File) + for i, dep := range deps { + fmt.Printf(" %d. %s\n", i+1, dep) + } + case astdiff.ChangeTypeRemoved: + deps, err := oldDeps.GetDependency(change.ObjectID) + if err != nil { + log.Fatalf("failed to get dependency: %v", err) + } + fmt.Printf("remove %s in %s, dependencies:\n", change.Object, change.File) + for i, dep := range deps { + fmt.Printf(" %d. %s\n", i+1, dep) + } + case astdiff.ChangeTypeModified: + deps, err := newDeps.GetDependency(change.ObjectID) + if err != nil { + log.Fatalf("failed to get dependency: %v", err) + } + fmt.Printf("modify %s in %s, dependencies:\n", change.Object, change.File) + for i, dep := range deps { + fmt.Printf(" %d. %s\n", i+1, dep) + } + } + } + case ScopeService: + // 只报告受影响的服务 + effectedServices := getEffectedServices(project.Services, oldDeps, newDeps, diff.Changes) + // fmt.Printf("受影响的entrypoint有:\n") + for _, service := range effectedServices { + fmt.Printf("%s\n", service) + } + default: + log.Fatalf("invalid scope: %s", scope) + } +} + +func exportCommit(commit, dir string) error { + // 确保目标目录存在 + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %v", dir, err) + } + + // 使用 git archive 导出指定版本 + cmd := exec.Command("git", "archive", "--format=tar", commit) + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to archive commit: %v", err) + } + + // 解压到临时目录 + cmd = exec.Command("tar", "-xf", "-") + cmd.Dir = dir + cmd.Stdin = strings.NewReader(string(output)) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to extract archive: %v", err) + } + + return nil +} + +func getEffectedServices(services map[string]parser.Service, oldDeps, newDeps *parser.DependencyInfo, changes []astdiff.Change) []string { + effecteds := make(map[string]bool) + for _, change := range changes { + switch change.Type { + case astdiff.ChangeTypeAdded: + deps, err := newDeps.GetDependency(change.ObjectID) + if err != nil { + log.Fatalf("failed to get dependency: %v", err) + } + for _, dep := range deps { + effecteds[dep] = true + } + case astdiff.ChangeTypeRemoved: + deps, err := oldDeps.GetDependency(change.ObjectID) + if err != nil { + log.Fatalf("failed to get dependency: %v", err) + } + for _, dep := range deps { + effecteds[dep] = true + } + case astdiff.ChangeTypeModified: + deps, err := newDeps.GetDependency(change.ObjectID) + if err != nil { + log.Fatalf("failed to get dependency: %v", err) + } + for _, dep := range deps { + effecteds[dep] = true + } + } + } + effectedServices := make([]string, 0, len(effecteds)) + for effected := range effecteds { + if svc, ok := services[effected]; ok { + effectedServices = append(effectedServices, svc.Name) + } + } + return effectedServices +} diff --git a/cmd/report.go b/cmd/report.go deleted file mode 100644 index 1736e0f..0000000 --- a/cmd/report.go +++ /dev/null @@ -1,73 +0,0 @@ -package cmd - -import ( - "fmt" - "log" - "os/exec" - "strings" - - "github.com/bootun/veronica/parser" - "github.com/spf13/cobra" -) - -var reportCmd = &cobra.Command{ - Use: "report", - Example: ` veronica report --old HEAD~1 --new HEAD`, - Short: "report the scope of impact of code changes", - Run: func(command *cobra.Command, args []string) { - if oldCommit == "" { - log.Fatal("Usage: veronica report --old --new ") - } - if newCommit == "" { - log.Fatal("Usage: veronica report --old --new ") - } - - cmd := exec.Command("git", "diff", "--name-only", oldCommit, newCommit) - output, err := cmd.CombinedOutput() - if err != nil { - log.Fatalf("git diff: %s", output) - } - - project, err := parser.NewProject(repo) - if err != nil { - log.Fatalf("load project: %s", err) - } - if err := project.Parse(); err != nil { - log.Fatalf("parse project: %s", err) - } - changedFiles := strings.Split(string(output), "\n") - entrypoints, err := project.GetAffectedEntrypoint(changedFiles) - if err != nil { - log.Fatalf("get affected entrypoint: %s", err) - } - switch outputFormat { - case OutputFormatOneLine: - for _, v := range entrypoints { - fmt.Printf("%s\n", v) - } - case OutputFormatText: - project.ReportImpact(changedFiles) - default: - log.Fatalf("unknown output format: %s", outputFormat) - } - }, -} - -var ( - outputFormat string - oldCommit string - newCommit string - repo string // 仓库路径 -) - -const ( - OutputFormatOneLine = "oneline" - OutputFormatText = "text" -) - -func init() { - reportCmd.Flags().StringVarP(&outputFormat, "format", "f", OutputFormatOneLine, "output format, options: oneline, text") - reportCmd.Flags().StringVarP(&oldCommit, "old", "o", "", "old commit") - reportCmd.Flags().StringVarP(&newCommit, "new", "n", "", "new commit") - reportCmd.Flags().StringVarP(&repo, "repo", "r", ".", "repo path") -} diff --git a/cmd/veronica.go b/cmd/veronica.go index 34e8463..c190e09 100644 --- a/cmd/veronica.go +++ b/cmd/veronica.go @@ -10,8 +10,8 @@ var rootCmd = &cobra.Command{ func init() { rootCmd.AddCommand(versionCmd) - rootCmd.AddCommand(reportCmd) rootCmd.AddCommand(dependencyCmd) + rootCmd.AddCommand(impactCmd) } func Execute() error { diff --git a/cmd/version.go b/cmd/version.go index d360d07..cd62f3d 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" ) -const Version = "0.1.5" +const Version = "1.0.0(alpha)" var versionCmd = &cobra.Command{ Use: "version", diff --git a/config/config.go b/config/config.go index 8ef3a64..209807e 100644 --- a/config/config.go +++ b/config/config.go @@ -8,10 +8,10 @@ import ( ) type Config struct { - Version string `yaml:"version"` + Version string `yaml:"version"` Services map[string]*Service `yaml:"services"` - GoMod string `yaml:"go.mod"` - Hooks []string `yaml:"hooks"` + GoMod string `yaml:"go.mod"` + Hooks []string `yaml:"hooks"` } type Service struct { @@ -24,12 +24,12 @@ type Service struct { func parseConfig(b []byte) (*Config, error) { var config Config - config.Services = map[string]*Service{} + config.Services = make(map[string]*Service) if err := yaml.Unmarshal(b, &config); err != nil { return nil, err } for k, v := range config.Services { - (*v).Name = k + v.Name = k } return &config, nil } diff --git a/parser/dependency.go b/parser/dependency.go index 456885b..f88840b 100644 --- a/parser/dependency.go +++ b/parser/dependency.go @@ -89,17 +89,7 @@ func (d *DependencyInfo) GetDependency(targetID string) ([]string, error) { } // BuildDependency 构建依赖关系图 -func BuildDependency(repoRoot string) (*DependencyInfo, error) { - // 加载包信息 - cfg := &packages.Config{ - Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo, - Dir: repoRoot, - } - pkgs, err := packages.Load(cfg, "./...") - if err != nil { - return nil, fmt.Errorf("parser project AST failed in %s: %v", repoRoot, err) - } - +func BuildDependency(pkgs []*packages.Package) (*DependencyInfo, error) { // nodesMap:key: 对象, value: 节点唯一标识 // 项目内所有的顶级声明 nodesMap := make(map[types.Object]string) @@ -246,7 +236,6 @@ func BuildDependency(repoRoot string) (*DependencyInfo, error) { } } - // key: 类型ID, value: 方法名 -> 节点ID typeMethodsMap := make(map[string]map[string]string) @@ -620,3 +609,16 @@ func signaturesCompatible(ifaceMethodSig, typeMethodSig *types.Signature) bool { return true } + +func LoadPackages(repo string) ([]*packages.Package, error) { + // 加载包信息 + cfg := &packages.Config{ + Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo, + Dir: repo, + } + pkgs, err := packages.Load(cfg, "./...") + if err != nil { + return nil, fmt.Errorf("parser project AST failed in %s: %v", repo, err) + } + return pkgs, nil +} diff --git a/parser/project.go b/parser/project.go index 081ce50..1348fde 100644 --- a/parser/project.go +++ b/parser/project.go @@ -1,11 +1,6 @@ package parser import ( - "fmt" - "go/parser" - "go/token" - "strings" - "github.com/pkg/errors" "github.com/bootun/veronica/config" @@ -45,9 +40,8 @@ func NewProject(root string) (*project, error) { if err != nil { return nil, errors.WithMessage(err, "failed to parse go.mod") } - + services := make(map[string]Service) // initialize entrypoint - entrypoint := make(map[string]*packageT) ignores := make(map[string][]string) hooks := make(map[string][]string) for _, v := range cfg.Services { @@ -56,25 +50,22 @@ func NewProject(root string) (*project, error) { if err != nil { return nil, errors.WithMessage(err, "failed to get relative path") } - entrypoint[relPath.String()] = &packageT{ + services[v.Entrypoint] = Service{ Name: v.Name, - Files: make(map[string]*fileT), - ImportedBy: make(map[string]*packageT), - Imports: make(map[string]*packageT), + Entrypoint: v.Entrypoint, + Ignores: v.Ignores, + Hooks: v.Hooks, } ignores[relPath.String()] = v.Ignores hooks[relPath.String()] = v.Hooks } // initialize project project := &project{ - directory: root, - Module: module, - GoFileCounter: 0, - Entrypoint: entrypoint, - parsed: false, - dependencies: make(map[string][]string), - Ignores: ignores, - Hooks: hooks, + directory: root, + Module: module, + Services: services, + Ignores: ignores, + Hooks: hooks, } return project, nil } @@ -83,259 +74,20 @@ func NewProject(root string) (*project, error) { type project struct { // Module records the information of go.mod Module *GoModuleInfo - // Entrypoint usually refers to main package of your services - Entrypoint map[string]*packageT - // number of go files - GoFileCounter int // TODO: 改用map,统计所有文件 + // key: service entrypoint, value: service info + Services map[string]Service // key is entrypoint package name, value is match pattern Ignores map[string][]string Hooks map[string][]string - parsed bool - // key is package name, value is the dependent entrypoint - dependencies map[string][]string // root directory of project directory string } -// maybe a better name :-( -type packageT struct { - // Name is package name, it only represents the path of - // the current directory relative to the root directory. - Name string - // Files contains all files under the current package - Files map[string]*fileT - // Imports represent package dependencies, key is package - // name - Imports map[string]*packageT - // ImportedBy means which packages are dependent on the - // current package - ImportedBy map[string]*packageT - - // NOTE: circular dependencies and different package names - onStack bool - walked bool -} - -func NewPackage(name string) *packageT { - return &packageT{ - Name: name, - Files: make(map[string]*fileT), - Imports: make(map[string]*packageT), - ImportedBy: make(map[string]*packageT), - } -} - -func (p *packageT) AddFile(file *fileT) error { - if file == nil { - return errors.New("file is empty") - } - p.Files[file.Name] = file - file.Package = p - return nil -} - -func (p *packageT) Import(pkg *packageT) error { - if pkg == nil { - return errors.New("package is empty") - } - p.Imports[pkg.Name] = pkg - pkg.ImportedBy[p.Name] = p - return nil -} - -type fileT struct { - // Name is the relative path to the root directory - Name string - // Package indicates the package to which the file belongs - Package *packageT - // Imports represent package dependencies, key is package name - Imports map[string]*packageT -} - -func NewFile(name string) *fileT { - return &fileT{ - Name: name, - Package: nil, - Imports: make(map[string]*packageT), - } -} - -func (p *project) IsParsed() bool { - return p.parsed -} - -// ParseProject parse project dependencies -func (p *project) Parse() error { - projectPath := path.New(p.directory) - if !projectPath.IsDir() { - return errors.Errorf("%s is not a directory", projectPath.String()) - } - - // all parsed packages, including which files are under the package, key is package name - var parsedPkg = make(map[string]*packageT) - - fset := token.NewFileSet() - - err := projectPath.Walk(func(curFilePath path.FilePath) error { - if !curFilePath.IsDir() && curFilePath.HasExt(".go") { - // increment counter - p.GoFileCounter++ - - file, err := parser.ParseFile(fset, curFilePath.String(), nil, parser.ImportsOnly) - if err != nil { - return err - } - - // relative path of file - relCurFile, err := projectPath.Rel(curFilePath.String()) - if err != nil { - return err - } - // relative name of current package - relCurPkg := curFilePath.Dir() - - curFile := NewFile(relCurFile.String()) - // create package if not exists - if p, exists := parsedPkg[relCurPkg.String()]; !exists { - pkg := NewPackage(relCurPkg.String()) - _ = pkg.AddFile(curFile) - parsedPkg[relCurPkg.String()] = pkg - } else { - p.AddFile(curFile) - } - - // parse imports - for _, importSpec := range file.Imports { - if !strings.Contains(importSpec.Path.Value, p.Module.Name) { - // skip standard library and external dependencies - continue - } - // relative import package - relCurImport := strings.TrimPrefix( - strings.Trim(importSpec.Path.Value, `"`), - p.Module.Name+"/", - ) - - // process current import package - if importPkg, parsed := parsedPkg[relCurImport]; parsed { - _ = parsedPkg[relCurPkg.String()].Import(importPkg) - } else { - importPkg := NewPackage(relCurImport) - parsedPkg[relCurImport] = importPkg - _ = parsedPkg[relCurPkg.String()].Import(importPkg) - } - - } - } - return nil - }) - if err != nil { - return err - } - - for k, _ := range p.Entrypoint { - if pkg, exists := parsedPkg[k]; exists { - p.Entrypoint[k] = pkg - } else { - return errors.Errorf("entrypoint %s not exists", k) - } - } - p.walkEntryPoint() - p.parsed = true - return nil -} - -func (p *project) walkEntryPoint() { - for entrypoint, pkg := range p.Entrypoint { - // key is package name, value is the dependent entrypoint - dependencies := make(map[string]*packageT) - WalkPackageDependencies(pkg, dependencies) - for k, _ := range dependencies { - p.dependencies[k] = append(p.dependencies[k], entrypoint) - } - } -} - -func WalkPackageDependencies(pkg *packageT, dependencies map[string]*packageT) { - if pkg.onStack { - // FIXME: 环 - return - } - - pkg.walked = true - pkg.onStack = true - for _, p := range pkg.Imports { - if _, walked := dependencies[p.Name]; !walked { - dependencies[p.Name] = p - } - WalkPackageDependencies(p, dependencies) - } - pkg.onStack = false -} - -func (p *project) ReportImpact(changed []string) { - if !p.parsed { - fmt.Printf("project not parsed\n") - return - } - for _, file := range changed { - fileDir := path.New(file).Dir() - if entrypoints, exists := p.dependencies[fileDir.String()]; exists { - fmt.Printf("改动了 %s 包中的 %s 文件,可能会影响这些包的构建:\n", fileDir, file) - for _, pkg := range entrypoints { - fmt.Printf(" - %s", pkg) - } - fmt.Println() - } - } -} - -// GetAffectedEntrypoint returns affected entrypoint, each entrypoint can only appear once at most. -func (p *project) GetAffectedEntrypoint(changed []string) ([]string, error) { - if !p.parsed { - return nil, errors.New("project not parsed") - } - - // record entrypoint that have been processed - processed := make(map[string]struct{}, len(p.Entrypoint)) - var result []string - for _, file := range changed { - pFile := path.New(file) - - filePkg := pFile.Dir() - for k, _ := range p.Entrypoint { - hooks := p.Hooks[k] - for _, hook := range hooks { - if pFile.Match(hook) { - if _, exists := processed[k]; !exists { - result = append(result, k) - processed[k] = struct{}{} - } - } - } - } - // if the file affects entrypoint, add it to result - if entrypoints, exists := p.dependencies[filePkg.String()]; exists { - affected: - // record affected entrypoint - for _, point := range entrypoints { - ignores := p.Ignores[point] - for _, ignore := range ignores { - if pFile.Match(ignore) { - continue affected - } - } - - // if the entrypoint has been processed, skip it - if _, exists := processed[point]; !exists { - result = append(result, point) - processed[point] = struct{}{} - } - } - - } - } - return result, nil +type Service struct { + Name string + Entrypoint string + Ignores []string + Hooks []string } diff --git a/veronica_example.yaml b/veronica_example.yaml index bbf8d91..107f90d 100644 --- a/veronica_example.yaml +++ b/veronica_example.yaml @@ -1,21 +1,21 @@ -version: 0.1.1 +version: 1.0.0-alpha services: # every item is a service - api-gateway: - # main package - entrypoint: cmd/api-gateway + refresh_playlet_info: + entrypoint: 'github.com/bootun/some-project/cmd/cron/refresh_playlet_info.go:NewRefreshPlayletInfoCronjob' ignores: - 'pkg/**/*doc.go' hooks: - '**/Makefile' - 'go.mod' + update_playlet: + entrypoint: 'cmd/consumer/update_playlet.go:UpdatePlaylet' - assets-manager: - entrypoint: cmd/assets-manager - - # the service name does not neeed to be the same as the entrypoint - assets-cron-deprecated: - entrypoint: cmd/assets-cron/v1 + # or gRPC interface + GRPC_GetPlayletInfo: + entrypoint: "github.com/bootun/some-project/internal/server/grpc.go:(*PlayletServer).GetPlayletInfo" + GRPC_BatchGetPlayletInfo: + entrypoint: "github.com/bootun/some-project/internal/server/grpc.go:(*PlayletServer).BatchGetPlayletInfo" - assets-cron-release: - entrypoint: cmd/assets-cron/v2 \ No newline at end of file + # or variable declare / type declare ... + # ... \ No newline at end of file