backend/core/migration/linter/main.go (166 lines of code) (raw):

/* Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file distributed with this work for additional information regarding copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package main import ( "bufio" "fmt" "go/ast" "go/parser" "go/token" "os" "path" "strconv" "strings" "text/template" "github.com/spf13/cobra" ) var moduleName = "" func init() { // prepare the module name line := firstLineFromFile("go.mod") moduleName = strings.Split(line, " ")[1] } func firstLineFromFile(path string) string { inFile, err := os.Open(path) if err != nil { panic(err) } defer inFile.Close() scanner := bufio.NewScanner(inFile) for scanner.Scan() { return scanner.Text() } panic(fmt.Errorf("empty file: " + path)) } const ( LINT_ERROR = "error" LINT_WARNING = "warning" ) type LintMessage struct { Level string File string Line int Col int EndCol int Title string Msg string } func lintMigrationScript(file string, allowedPkgs map[string]bool) []LintMessage { msgs := make([]LintMessage, 0) src, err := os.ReadFile(file) if err != nil { msgs = append(msgs, LintMessage{ Level: LINT_ERROR, File: file, Title: "Error reading file", Msg: err.Error(), }) return msgs } fset := token.NewFileSet() f, err := parser.ParseFile(fset, file, src, 0) if err != nil { msgs = append(msgs, LintMessage{ Level: LINT_ERROR, File: file, Title: "Error parsing file", Msg: err.Error(), }) return msgs } // ast.Print(fset, f) ast.Inspect(f, func(n ast.Node) bool { switch x := n.(type) { case *ast.ImportSpec: importedPkgName, err := strconv.Unquote(x.Path.Value) if err != nil { panic(err) } // it is ok to use subpackages filePkgName := path.Join(moduleName, path.Dir(file)) if strings.HasPrefix(importedPkgName, filePkgName) { return true } // it is ok to use external libs, their behaviors are considered stable if !strings.HasPrefix(importedPkgName, moduleName) { return true } // it is ok if the package is whitelisted if allowedPkgs[importedPkgName] { return true } // we have a problem // migration scripts are Immutable, meaning their behaviors should not be changed over time // Relying on other packages may break the constraint and cause unexpected side-effects. // You may add the package to the whitelist by the -a option if you are sure it is OK pos := fset.Position(n.Pos()) msgs = append(msgs, LintMessage{ Level: LINT_WARNING, File: file, Title: "Package not allowed", Msg: fmt.Sprintf("%s imports forbidden package %s", file, x.Path.Value), Line: pos.Line, Col: pos.Column, EndCol: pos.Column + len(x.Path.Value), }) } return true }) return msgs } func main() { cmd := &cobra.Command{Use: "migration script linter"} prefix := cmd.Flags().StringP("prefix", "p", "", "path prefix if your go.mod resides in a subfolder") allowedPkg := cmd.Flags().StringArrayP( "allowed-pkg", "a", []string{ "github.com/apache/incubator-devlake/core/config", "github.com/apache/incubator-devlake/core/context", "github.com/apache/incubator-devlake/core/dal", "github.com/apache/incubator-devlake/core/errors", "github.com/apache/incubator-devlake/helpers/migrationhelper", "github.com/apache/incubator-devlake/core/models/migrationscripts/archived", "github.com/apache/incubator-devlake/core/plugin", "github.com/apache/incubator-devlake/helpers/pluginhelper/api", }, "package that allowed to be used in a migration script. e.g.: github.com/apache/incubator-devlake/core/context", ) cmd.Run = func(cmd *cobra.Command, args []string) { allowedPkgs := make(map[string]bool, len(*allowedPkg)) for _, p := range *allowedPkg { allowedPkgs[p] = true } warningTpl, err := template.New("warning").Parse("::warning file={{ .File }},line={{ .Line }},col={{ .Col }},endColumn={{ .EndCol }}::{{ .Msg }}") if err != nil { panic(err) } errorTpl, err := template.New("error").Parse("::error file={{ .File }},line={{ .Line }},endLine={{ .Col }},title={{ .Title }}::{{ .Msg }}") if err != nil { panic(err) } localTpl, err := template.New("local").Parse("{{ .Level }}: {{ .Msg }}\n\t{{ .File }}:{{ .Line }}:{{ .Col }}") if err != nil { panic(err) } exitCode := 0 for _, file := range args { msgs := lintMigrationScript(file, allowedPkgs) if len(msgs) == 0 { continue } for _, msg := range msgs { var tpl *template.Template if *prefix != "" { // github actions need root relative path for annotation to show up in the PR msg.File = path.Join(*prefix, file) tpl = errorTpl if msg.Level == LINT_WARNING { tpl = warningTpl } } else { // we are running locally in the `backend` folder, use another format to make fixing easier tpl = localTpl } err = tpl.Execute(os.Stderr, msg) if err != nil { panic(err) } os.Stderr.WriteString("\n") exitCode = 1 } } os.Exit(exitCode) } err := cmd.Execute() if err != nil { panic(err) } }